refactor: remove legacy data compatibility
This commit is contained in:
+19
-80
@@ -1,10 +1,11 @@
|
|||||||
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
|
import { readJsonFile } from "../utils/fileStore.js";
|
||||||
import {
|
import {
|
||||||
type ResultReferenceKind,
|
type ResultReferenceKind,
|
||||||
type ResultReferenceRecord,
|
type ResultReferenceRecord,
|
||||||
type ResultReferenceSource,
|
type ResultReferenceSource,
|
||||||
type RetrievalContext,
|
type RetrievalContext,
|
||||||
RESULT_REFERENCE_KIND,
|
RESULT_REFERENCE_KIND,
|
||||||
|
RESULT_REFERENCE_SOURCE,
|
||||||
type ResultReferenceStore,
|
type ResultReferenceStore,
|
||||||
} from "./store.js";
|
} from "./store.js";
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export type RenderJunctionPayload = {
|
|||||||
export class ResultReferenceResolver {
|
export class ResultReferenceResolver {
|
||||||
constructor(private readonly store: ResultReferenceStore) {}
|
constructor(private readonly store: ResultReferenceStore) {}
|
||||||
|
|
||||||
|
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
|
||||||
async register(input: RegisterResultReferenceInput) {
|
async register(input: RegisterResultReferenceInput) {
|
||||||
const normalizedData = normalizeDataForKind(
|
const normalizedData = normalizeDataForKind(
|
||||||
input.kind,
|
input.kind,
|
||||||
@@ -66,15 +68,12 @@ export class ResultReferenceResolver {
|
|||||||
throw new Error(`render payload file not found: ${filePath}`);
|
throw new Error(`render payload file not found: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadCandidate = normalizeRenderPayloadFile(raw, {
|
const wrapper = normalizeRenderPayloadFile(raw, filePath);
|
||||||
filePath,
|
if (!wrapper) {
|
||||||
projectId: input.projectId,
|
throw new Error("render payload file must use the wrapped { metadata, location, data } format");
|
||||||
});
|
|
||||||
if (payloadCandidate.repaired) {
|
|
||||||
await atomicWriteJson(filePath, payloadCandidate.file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = extractRenderJunctionPayload(payloadCandidate.data);
|
const payload = extractRenderJunctionPayload(wrapper.data);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new Error("render payload file does not contain a valid junction render payload");
|
throw new Error("render payload file does not contain a valid junction render payload");
|
||||||
}
|
}
|
||||||
@@ -84,6 +83,7 @@ export class ResultReferenceResolver {
|
|||||||
data: payload,
|
data: payload,
|
||||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +144,7 @@ export const extractRenderJunctionPayload = (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 节点渲染结果只保留前端真正需要的映射字段,剔除空值并统一转为字符串。
|
||||||
const nodeAreaMap = normalizeStringRecord(candidate.node_area_map);
|
const nodeAreaMap = normalizeStringRecord(candidate.node_area_map);
|
||||||
if (Object.keys(nodeAreaMap).length === 0) {
|
if (Object.keys(nodeAreaMap).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -182,35 +183,18 @@ const normalizeDataForKind = (
|
|||||||
|
|
||||||
const normalizeRenderPayloadFile = (
|
const normalizeRenderPayloadFile = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
context: { filePath: string; projectId?: string },
|
filePath: string,
|
||||||
): { data: unknown; file: Record<string, unknown>; repaired: boolean } => {
|
): { data: unknown } | null => {
|
||||||
if (!isRecord(value) || !("data" in value)) {
|
if (!isRecord(value) || !("data" in value)) {
|
||||||
return {
|
return null;
|
||||||
data: value,
|
|
||||||
file: {
|
|
||||||
metadata: buildWrapperMetadata({}, value, context.projectId),
|
|
||||||
location: buildWrapperLocation(undefined, context.filePath),
|
|
||||||
data: value,
|
|
||||||
},
|
|
||||||
repaired: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
if (!isRecord(value.metadata) || !isRecord(value.location)) {
|
||||||
const metadata = buildWrapperMetadata(value.metadata, value, context.projectId);
|
return null;
|
||||||
const location = buildWrapperLocation(value.location, context.filePath);
|
}
|
||||||
const next: Record<string, unknown> = {
|
if (value.location.file_path !== filePath) {
|
||||||
...value,
|
return null;
|
||||||
metadata,
|
}
|
||||||
location,
|
return { data: value.data };
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: next.data,
|
|
||||||
file: next,
|
|
||||||
repaired:
|
|
||||||
JSON.stringify(metadata) !== JSON.stringify(value.metadata ?? null) ||
|
|
||||||
JSON.stringify(location) !== JSON.stringify(value.location ?? null),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
|
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
|
||||||
@@ -232,48 +216,3 @@ const normalizeStringRecord = (value: Record<string, unknown>) =>
|
|||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
const buildWrapperMetadata = (
|
|
||||||
value: unknown,
|
|
||||||
root: unknown,
|
|
||||||
fallbackProjectId?: string,
|
|
||||||
) => {
|
|
||||||
const metadata = isRecord(value) ? { ...value } : {};
|
|
||||||
const source = isRecord(root) ? root : {};
|
|
||||||
|
|
||||||
if (typeof metadata.createdAt !== "string" || metadata.createdAt.trim().length === 0) {
|
|
||||||
const createdAt =
|
|
||||||
typeof source.createdAt === "string" && source.createdAt.trim().length > 0
|
|
||||||
? source.createdAt.trim()
|
|
||||||
: new Date().toISOString();
|
|
||||||
metadata.createdAt = createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof metadata.projectId !== "string" ||
|
|
||||||
metadata.projectId.trim().length === 0
|
|
||||||
) {
|
|
||||||
const projectId =
|
|
||||||
typeof source.projectId === "string" && source.projectId.trim().length > 0
|
|
||||||
? source.projectId.trim()
|
|
||||||
: fallbackProjectId;
|
|
||||||
if (projectId) {
|
|
||||||
metadata.projectId = projectId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildWrapperLocation = (value: unknown, filePath: string) => {
|
|
||||||
if (isRecord(value)) {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
file_path: filePath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
file_path: filePath,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
+6
-61
@@ -10,7 +10,6 @@ import {
|
|||||||
listJsonFiles,
|
listJsonFiles,
|
||||||
readJsonFile,
|
readJsonFile,
|
||||||
removeFileIfExists,
|
removeFileIfExists,
|
||||||
toProjectKey,
|
|
||||||
} from "../utils/fileStore.js";
|
} from "../utils/fileStore.js";
|
||||||
|
|
||||||
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
|
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
|
||||||
@@ -22,8 +21,6 @@ export const RESULT_REFERENCE_KIND = {
|
|||||||
|
|
||||||
export const RESULT_REFERENCE_SOURCE = {
|
export const RESULT_REFERENCE_SOURCE = {
|
||||||
agentGenerated: "agent_generated",
|
agentGenerated: "agent_generated",
|
||||||
legacy: "legacy",
|
|
||||||
migration: "migration",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ResultReferenceKind =
|
export type ResultReferenceKind =
|
||||||
@@ -133,6 +130,7 @@ export class ResultReferenceStore {
|
|||||||
source: input.source,
|
source: input.source,
|
||||||
traceId: input.traceId,
|
traceId: input.traceId,
|
||||||
};
|
};
|
||||||
|
// result_ref 对外暴露短引用,完整数据落盘;这样可以避免大结果直接塞进模型上下文。
|
||||||
await atomicWriteJson(this.filePath(resultRef), record);
|
await atomicWriteJson(this.filePath(resultRef), record);
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
@@ -143,13 +141,13 @@ export class ResultReferenceStore {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawRecord = await readJsonFile<unknown>(this.filePath(normalizedResultRef));
|
const record = normalizeResultReferenceRecord(
|
||||||
const record =
|
await readJsonFile<unknown>(this.filePath(normalizedResultRef)),
|
||||||
normalizeResultReferenceRecord(rawRecord) ??
|
);
|
||||||
normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context);
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// 读取 result_ref 时按用户、项目和可选会话三层校验,防止跨项目/跨用户取数。
|
||||||
if (record.actorKey !== context.actorKey) {
|
if (record.actorKey !== context.actorKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -202,6 +200,7 @@ export class ResultReferenceStore {
|
|||||||
if (!stats) {
|
if (!stats) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// TTL 以文件修改时间为准,清理长期无人访问的 result_ref 文件。
|
||||||
if (now - stats.mtimeMs > this.ttlMs) {
|
if (now - stats.mtimeMs > this.ttlMs) {
|
||||||
await removeFileIfExists(filePath);
|
await removeFileIfExists(filePath);
|
||||||
}
|
}
|
||||||
@@ -303,60 +302,6 @@ const normalizeResultRef = (value: string) => {
|
|||||||
return match?.[1] ?? null;
|
return match?.[1] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeLegacyRenderReferenceRecord = (
|
|
||||||
value: unknown,
|
|
||||||
resultRef: string,
|
|
||||||
context: RetrievalContext,
|
|
||||||
): ResultReferenceRecord | null => {
|
|
||||||
const data = extractLegacyRenderPayload(value);
|
|
||||||
if (!data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = isRecord(value) ? value : {};
|
|
||||||
const metadata = isRecord(root.metadata) ? root.metadata : {};
|
|
||||||
const projectId = firstNonEmptyString(root.projectId, metadata.projectId);
|
|
||||||
const createdAt =
|
|
||||||
firstNonEmptyString(root.createdAt, metadata.createdAt) ?? new Date().toISOString();
|
|
||||||
|
|
||||||
return {
|
|
||||||
resultRef,
|
|
||||||
actorKey: context.actorKey,
|
|
||||||
clientSessionId: context.clientSessionId ?? "",
|
|
||||||
createdAt,
|
|
||||||
data,
|
|
||||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
|
||||||
preview: buildPreview(data),
|
|
||||||
projectId,
|
|
||||||
projectKey: toProjectKey(projectId),
|
|
||||||
schemaVersion: 1,
|
|
||||||
sessionId: context.clientSessionId ?? resultRef,
|
|
||||||
sizeBytes: estimateBytes(data),
|
|
||||||
source: RESULT_REFERENCE_SOURCE.legacy,
|
|
||||||
traceId: "legacy-render-ref",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractLegacyRenderPayload = (value: unknown) => {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const candidate = isRecord(value.data) ? value.data : value;
|
|
||||||
if (!isRecord(candidate.node_area_map)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return candidate;
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstNonEmptyString = (...values: unknown[]) => {
|
|
||||||
for (const value of values) {
|
|
||||||
if (typeof value === "string" && value.trim().length > 0) {
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isResultPreview = (value: unknown): value is ResultPreview =>
|
const isResultPreview = (value: unknown): value is ResultPreview =>
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
typeof value.count === "number" &&
|
typeof value.count === "number" &&
|
||||||
|
|||||||
+7
-2
@@ -12,12 +12,17 @@ import { logger } from "./logger.js";
|
|||||||
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
||||||
import { MemoryStore } from "./memory/store.js";
|
import { MemoryStore } from "./memory/store.js";
|
||||||
import { ResultReferenceResolver } from "./results/resolver.js";
|
import { ResultReferenceResolver } from "./results/resolver.js";
|
||||||
import { ResultReferenceStore } from "./results/store.js";
|
import {
|
||||||
|
RESULT_REFERENCE_SOURCE,
|
||||||
|
ResultReferenceStore,
|
||||||
|
} from "./results/store.js";
|
||||||
import { buildChatRouter } from "./routes/chat.js";
|
import { buildChatRouter } from "./routes/chat.js";
|
||||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||||
import { SessionRuntimeContextStore } from "./sessions/runtimeContextStore.js";
|
import { SessionRuntimeContextStore } from "./sessions/runtimeContextStore.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// 这里集中组装 Agent 服务的运行期依赖,路由层只通过接口调用,便于测试时替换实现。
|
||||||
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
||||||
const sessionMetadataStore = new SessionMetadataStore();
|
const sessionMetadataStore = new SessionMetadataStore();
|
||||||
const sessionUiStateStore = new SessionUiStateStore();
|
const sessionUiStateStore = new SessionUiStateStore();
|
||||||
@@ -190,7 +195,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
|
|||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
projectKey: context.projectKey,
|
projectKey: context.projectKey,
|
||||||
sessionId: context.clientSessionId,
|
sessionId: context.clientSessionId,
|
||||||
source: "migration",
|
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||||
traceId: context.traceId,
|
traceId: context.traceId,
|
||||||
});
|
});
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ type SessionTranscriptContext = {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SEARCH_MAX_RESULTS = 8;
|
||||||
|
const DEFAULT_SEARCH_MAX_QUERY_CHARS = 240;
|
||||||
|
|
||||||
export class SessionTranscriptStore {
|
export class SessionTranscriptStore {
|
||||||
private readonly writeQueues = new Map<string, Promise<void>>();
|
private readonly writeQueues = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
@@ -62,6 +65,7 @@ export class SessionTranscriptStore {
|
|||||||
) {
|
) {
|
||||||
const key = this.filePath(context);
|
const key = this.filePath(context);
|
||||||
return this.serializeWrite(key, async () => {
|
return this.serializeWrite(key, async () => {
|
||||||
|
// 同一会话的多次写入串行化,防止流式结束和 UI 同步同时写同一个 transcript。
|
||||||
const transcript = (await this.readTranscript(context)) ?? {
|
const transcript = (await this.readTranscript(context)) ?? {
|
||||||
actorKey: context.actorKey,
|
actorKey: context.actorKey,
|
||||||
clientSessionId: context.clientSessionId,
|
clientSessionId: context.clientSessionId,
|
||||||
@@ -82,6 +86,7 @@ export class SessionTranscriptStore {
|
|||||||
lastTurn?.userMessage === userMessage &&
|
lastTurn?.userMessage === userMessage &&
|
||||||
lastTurn.assistantMessage === assistantMessage
|
lastTurn.assistantMessage === assistantMessage
|
||||||
) {
|
) {
|
||||||
|
// 相同问答重复写入时只更新工具调用数量,避免刷新/重试造成 transcript 重复膨胀。
|
||||||
lastTurn.toolCallCount = Math.max(lastTurn.toolCallCount, turn.toolCallCount);
|
lastTurn.toolCallCount = Math.max(lastTurn.toolCallCount, turn.toolCallCount);
|
||||||
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
|
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
|
||||||
transcript.sessionId = context.sessionId;
|
transcript.sessionId = context.sessionId;
|
||||||
@@ -129,6 +134,7 @@ export class SessionTranscriptStore {
|
|||||||
) {
|
) {
|
||||||
const sourceTranscript = await this.readTranscript(sourceContext);
|
const sourceTranscript = await this.readTranscript(sourceContext);
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
|
// 分叉会话只复制用户选择保留的上下文,后续新分支拥有独立 transcript 文件。
|
||||||
const nextTranscript: SessionTranscriptRecord = {
|
const nextTranscript: SessionTranscriptRecord = {
|
||||||
actorKey: targetContext.actorKey,
|
actorKey: targetContext.actorKey,
|
||||||
clientSessionId: targetContext.clientSessionId,
|
clientSessionId: targetContext.clientSessionId,
|
||||||
@@ -144,9 +150,10 @@ export class SessionTranscriptStore {
|
|||||||
async search(
|
async search(
|
||||||
context: Pick<SessionTranscriptContext, "actorKey" | "projectKey">,
|
context: Pick<SessionTranscriptContext, "actorKey" | "projectKey">,
|
||||||
query: string,
|
query: string,
|
||||||
maxResults = config.SESSION_SEARCH_MAX_RESULTS,
|
maxResults = DEFAULT_SEARCH_MAX_RESULTS,
|
||||||
): Promise<SessionSearchHit[]> {
|
): Promise<SessionSearchHit[]> {
|
||||||
const normalizedQuery = query.trim().toLowerCase().slice(0, config.SESSION_SEARCH_MAX_QUERY_CHARS);
|
// 当前搜索是轻量本地文本匹配,按 actor/project 过滤后再计算简单相关性分数。
|
||||||
|
const normalizedQuery = query.trim().toLowerCase().slice(0, DEFAULT_SEARCH_MAX_QUERY_CHARS);
|
||||||
if (!normalizedQuery) {
|
if (!normalizedQuery) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -189,38 +196,7 @@ export class SessionTranscriptStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async readTranscript(context: SessionTranscriptContext) {
|
private async readTranscript(context: SessionTranscriptContext) {
|
||||||
const direct = await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
|
return await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
|
||||||
if (direct) {
|
|
||||||
return direct;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientSessionId = context.clientSessionId?.trim();
|
|
||||||
if (!clientSessionId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await listJsonFiles(this.baseDir);
|
|
||||||
const matches: SessionTranscriptRecord[] = [];
|
|
||||||
for (const file of files) {
|
|
||||||
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
|
|
||||||
if (!transcript) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
transcript.actorKey !== context.actorKey ||
|
|
||||||
transcript.projectKey !== context.projectKey ||
|
|
||||||
transcript.clientSessionId !== clientSessionId
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
matches.push(transcript);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private filePath(context: SessionTranscriptContext) {
|
private filePath(context: SessionTranscriptContext) {
|
||||||
|
|||||||
+3
-177
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -164,12 +164,12 @@ describe("ResultReferenceResolver", () => {
|
|||||||
projectId: "project-3",
|
projectId: "project-3",
|
||||||
projectKey: "project-key-3",
|
projectKey: "project-key-3",
|
||||||
sessionId: "session-3",
|
sessionId: "session-3",
|
||||||
source: RESULT_REFERENCE_SOURCE.migration,
|
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||||
traceId: "trace-3",
|
traceId: "trace-3",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||||
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.migration);
|
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
|
||||||
|
|
||||||
const result = await resolver.getFullAuthorized(
|
const result = await resolver.getFullAuthorized(
|
||||||
record.resultRef,
|
record.resultRef,
|
||||||
@@ -195,178 +195,4 @@ describe("ResultReferenceResolver", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("repairs wrapper files that omit metadata and location", async () => {
|
|
||||||
const filePath = join(tempDir, "render-wrapper-missing-fields.json");
|
|
||||||
await writeFile(
|
|
||||||
filePath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
node_area_map: {
|
|
||||||
J1: "DMA-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createdAt: "2026-05-21T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const record = await resolver.registerRenderPayloadFile(filePath, {
|
|
||||||
actorKey: "actor-4",
|
|
||||||
clientSessionId: "client-4",
|
|
||||||
projectId: "project-4",
|
|
||||||
projectKey: "project-key-4",
|
|
||||||
sessionId: "session-4",
|
|
||||||
source: RESULT_REFERENCE_SOURCE.migration,
|
|
||||||
traceId: "trace-4",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
|
||||||
|
|
||||||
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
location?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(repaired.metadata).toEqual({
|
|
||||||
createdAt: "2026-05-21T00:00:00.000Z",
|
|
||||||
projectId: "project-4",
|
|
||||||
});
|
|
||||||
expect(repaired.location).toEqual({
|
|
||||||
file_path: filePath,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("repairs wrapper files whose location points elsewhere", async () => {
|
|
||||||
const filePath = join(tempDir, "render-wrapper-wrong-location.json");
|
|
||||||
await writeFile(
|
|
||||||
filePath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
metadata: {
|
|
||||||
createdAt: "2026-05-21T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
location: {
|
|
||||||
file_path: "/tmp/elsewhere.json",
|
|
||||||
source: "legacy",
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
node_area_map: {
|
|
||||||
J1: "DMA-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
await resolver.registerRenderPayloadFile(filePath, {
|
|
||||||
actorKey: "actor-4",
|
|
||||||
clientSessionId: "client-4",
|
|
||||||
projectId: "project-4",
|
|
||||||
projectKey: "project-key-4",
|
|
||||||
sessionId: "session-4",
|
|
||||||
source: RESULT_REFERENCE_SOURCE.migration,
|
|
||||||
traceId: "trace-4",
|
|
||||||
});
|
|
||||||
|
|
||||||
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
location?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(repaired.metadata).toEqual({
|
|
||||||
createdAt: "2026-05-21T00:00:00.000Z",
|
|
||||||
projectId: "project-4",
|
|
||||||
});
|
|
||||||
expect(repaired.location).toEqual({
|
|
||||||
file_path: filePath,
|
|
||||||
source: "legacy",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves legacy render payload files when callers include the json suffix", async () => {
|
|
||||||
const legacyRef = "res-c2fcee33-577e";
|
|
||||||
await writeFile(
|
|
||||||
join(tempDir, `${legacyRef}.json`),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
node_area_map: {
|
|
||||||
J1: "DMA-1",
|
|
||||||
J2: 2,
|
|
||||||
},
|
|
||||||
area_ids: ["DMA-1"],
|
|
||||||
},
|
|
||||||
createdAt: "2026-05-21T00:00:00.000Z",
|
|
||||||
projectId: "project-legacy-render",
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await resolver.getFullAuthorized(
|
|
||||||
`${legacyRef}.json`,
|
|
||||||
{
|
|
||||||
actorKey: "actor-legacy-render",
|
|
||||||
clientSessionId: "chat-legacy-render",
|
|
||||||
projectId: "project-legacy-render",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result?.result_ref).toBe(legacyRef);
|
|
||||||
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
|
||||||
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.legacy);
|
|
||||||
expect(result?.data).toEqual({
|
|
||||||
node_area_map: {
|
|
||||||
J1: "DMA-1",
|
|
||||||
J2: "2",
|
|
||||||
},
|
|
||||||
area_ids: ["DMA-1"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps legacy render payload files scoped to their project", async () => {
|
|
||||||
const legacyRef = "res-dddddddddddddddd";
|
|
||||||
await writeFile(
|
|
||||||
join(tempDir, `${legacyRef}.json`),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
node_area_map: {
|
|
||||||
J1: "DMA-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
projectId: "project-allowed",
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await resolver.getFullAuthorized(
|
|
||||||
legacyRef,
|
|
||||||
{
|
|
||||||
actorKey: "actor-legacy-render",
|
|
||||||
clientSessionId: "chat-legacy-render",
|
|
||||||
projectId: "project-denied",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -19,63 +19,6 @@ describe("SessionTranscriptStore", () => {
|
|||||||
await rm(tempDir, { force: true, recursive: true });
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to legacy runtime-session transcripts by client session id and migrates on append", async () => {
|
|
||||||
await writeFile(
|
|
||||||
join(tempDir, "actor-1__project-1__runtime-session-1.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
actorKey: "actor-1",
|
|
||||||
clientSessionId: "thread-1",
|
|
||||||
projectKey: "project-1",
|
|
||||||
sessionId: "runtime-session-1",
|
|
||||||
turns: [
|
|
||||||
{
|
|
||||||
id: "turn-1",
|
|
||||||
assistantMessage: "先检查泵站流量。",
|
|
||||||
timestamp: "2026-05-21T00:00:00.000Z",
|
|
||||||
toolCallCount: 1,
|
|
||||||
userMessage: "帮我看一下当前异常。",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
updatedAt: "2026-05-21T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const recentTurns = await store.getRecentTurns(
|
|
||||||
{
|
|
||||||
actorKey: "actor-1",
|
|
||||||
clientSessionId: "thread-1",
|
|
||||||
projectKey: "project-1",
|
|
||||||
sessionId: "thread-1",
|
|
||||||
},
|
|
||||||
5,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(recentTurns).toHaveLength(1);
|
|
||||||
expect(recentTurns[0]?.userMessage).toBe("帮我看一下当前异常。");
|
|
||||||
|
|
||||||
const transcript = await store.appendTurn(
|
|
||||||
{
|
|
||||||
actorKey: "actor-1",
|
|
||||||
clientSessionId: "thread-1",
|
|
||||||
projectKey: "project-1",
|
|
||||||
sessionId: "thread-1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
assistantMessage: "已经定位到 3 条疑似异常支路。",
|
|
||||||
toolCallCount: 2,
|
|
||||||
userMessage: "继续分析这些支路。",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(transcript.sessionId).toBe("thread-1");
|
|
||||||
expect(transcript.turns).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clones only the kept prefix when forking a thread", async () => {
|
it("clones only the kept prefix when forking a thread", async () => {
|
||||||
await store.appendTurn(
|
await store.appendTurn(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user