diff --git a/apps/api/src/resolve-routing-context.ts b/apps/api/src/resolve-routing-context.ts new file mode 100644 index 00000000..66901b4a --- /dev/null +++ b/apps/api/src/resolve-routing-context.ts @@ -0,0 +1,68 @@ +import type { ProtocolRunState, RoutingContext } from "@datafoundry/agent-runtime"; +import type { MetadataStore } from "@datafoundry/metadata"; + +import { MetadataProtocolStateStore } from "./protocol-state-store.js"; + +type ResolveRoutingContextInput = { + metadataStore: MetadataStore; + /** Current run id (excluded when looking up the previous run). */ + runId: string; + selectedDatasourceId?: string; + /** Skill IDs active this run, if any. */ + selectedSkillIds?: string[]; + sessionId: string; + userId: string; +}; + +/** + * Resolve the compact routing context for the protocol classifier from the + * previous run in the same session. Lets short follow-ups such as "再次尝试" + * ("try again") inherit the prior data-analysis intent instead of being routed + * to general-task on the ambiguous text alone. + * + * Returns undefined when there is no prior run in the session (first turn), + * so callers without history are unaffected. + */ +export const resolveRoutingContext = (input: ResolveRoutingContextInput): RoutingContext | undefined => { + const previousRun = input.metadataStore.runs.findPreviousRunBySession({ + user_id: input.userId, + session_id: input.sessionId, + exclude_run_id: input.runId + }); + if (!previousRun) { + return undefined; + } + const protocolStateStore = new MetadataProtocolStateStore(input.metadataStore, input.userId); + const previousProtocolState = protocolStateStore.find(previousRun.id); + const previousQuery = previousRun.user_input.trim() ? previousRun.user_input : null; + const previousProtocol = previousProtocolState + ? buildPreviousProtocol(previousProtocolState) + : null; + const selectedSkillIds = input.selectedSkillIds?.length ? [...input.selectedSkillIds] : null; + const selectedDatasourceId = input.selectedDatasourceId ?? null; + if (!previousQuery && !previousProtocol && !selectedSkillIds && !selectedDatasourceId) { + return undefined; + } + const routingContext: RoutingContext = { + ...(previousQuery ? { previousQuery } : {}), + ...(previousProtocol ? { previousProtocol } : {}), + ...(selectedSkillIds ? { selectedSkillIds } : {}), + ...(selectedDatasourceId ? { selectedDatasourceId } : {}) + }; + return routingContext; +}; + +const buildPreviousProtocol = (state: ProtocolRunState): { + protocolId: string; + protocolVersion: string; + terminalStatus?: string; +} => { + const terminalStatus = state.status === "terminal" + ? state.terminalDecision?.status + : state.status; + return { + protocolId: state.protocolId, + protocolVersion: state.protocolVersion, + ...(terminalStatus ? { terminalStatus } : {}) + }; +}; diff --git a/apps/api/src/run-agent-assembly.ts b/apps/api/src/run-agent-assembly.ts index a5d56e7d..0aa2fc71 100644 --- a/apps/api/src/run-agent-assembly.ts +++ b/apps/api/src/run-agent-assembly.ts @@ -13,6 +13,7 @@ import { type RunProtocolBoundary, type ContextPackageRef, type ProtocolStateStore, + type RoutingContext, type TaskStateRuntime, type WorkspaceAttachment } from "@datafoundry/agent-runtime"; @@ -84,6 +85,10 @@ type CreateRunAgentAssemblyInput = { userId: string; workspaceId: string; workspaceRoot: string; + /** Compact routing context resolved by the caller from the previous run's + * protocol snapshot + selected resources. Lets follow-up intents inherit the + * prior protocol so short prompts route correctly. */ + routingContext?: RoutingContext; }; /** Create the canonical agent run context used by Mastra tools, projections, and metadata. */ @@ -174,6 +179,7 @@ export const createRunAgentAssembly = async ( selectedSkills: input.selectedSkills, skillSelection: input.skillSelection, taskStateRuntime: input.taskStateRuntime, + ...(input.routingContext ? { routingContext: input.routingContext } : {}), ...(!input.interactionResume && input.goal ? { goal: input.goal } : {}), ...(input.effectiveRunConfig.fileIds.length > 0 ? { workspaceAttachments: resolveWorkspaceAttachments(input) } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 9ab730b6..dbc0d675 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -56,6 +56,7 @@ import { replayPendingProtocolEvents } from "./protocol-event-recovery.js"; import { assistantMessageIdFromEvent, completeProtocolRun } from "./protocol-run-completion.js"; import { persistCurrentUserMessage } from "./conversation-memory.js"; import { resolveEvidenceReferenceContext } from "./evidence-reference-context.js"; +import { resolveRoutingContext } from "./resolve-routing-context.js"; import { createRunAgentAssembly, createRunAgentContext } from "./run-agent-assembly.js"; import { RunCheckpointProjector } from "./run-checkpoint-projector.js"; import { TraceSectionCoordinator } from "./trace-section-coordinator.js"; @@ -643,6 +644,14 @@ class DataFoundryAgUiAgent extends AbstractAgent { eventPipeline.emit(event); }; replayPendingProtocolEvents({ runId, stateStore: protocolStateStore, emit }); + const routingContext = resolveRoutingContext({ + metadataStore: this.input.metadataStore, + runId, + ...(effectiveRunConfig.skillIds.length > 0 ? { selectedSkillIds: effectiveRunConfig.skillIds } : {}), + ...(selectedDatasourceId ? { selectedDatasourceId } : {}), + sessionId, + userId: this.input.user.id + }); const agentAssembly = await createRunAgentAssembly({ abortSignal: runAbortController.signal, contextPackageRecorder, @@ -675,6 +684,7 @@ class DataFoundryAgUiAgent extends AbstractAgent { selectedSkills, skillSelection, taskStateRuntime: this.input.taskStateRuntime, + ...(routingContext ? { routingContext } : {}), userId: this.input.user.id, workspaceId: this.input.workspaceId, workspaceRoot: this.input.workspaceRoot diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 0361ecd7..739295f0 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -80,7 +80,8 @@ import { createTool, type ToolAction } from "@mastra/core/tools"; import { z } from "zod"; import { createRunProtocolBoundary, - type RunProtocolBoundary + type RunProtocolBoundary, + type RoutingContext } from "./protocol/run-protocol-boundary.js"; import type { ProtocolClassifier, ProtocolIdentity } from "./protocol/protocol-router.js"; import { createModelProtocolClassifier } from "./protocol/model-protocol-classifier.js"; @@ -253,6 +254,10 @@ export type CreateDataFoundryInput = { protocolClassifier?: ProtocolClassifier; analysisRequirementExtractor?: AnalysisRequirementExtractor; analysisContractGrounder?: AnalysisContractGrounder; + /** Compact routing context for follow-up intents, sourced by the caller from the + * previous run's protocol snapshot + selected resources. Lets the protocol + * classifier see prior intent so short follow-ups inherit the right protocol. */ + routingContext?: RoutingContext; onProtocolEvent?(event: ProtocolEvent): void; protocolStateStore?: ProtocolStateStore; resourceRevisions?: Record; @@ -504,6 +509,7 @@ export const createDataFoundry = async ( } : {}), ...(input.explicitProtocol ? { explicitProtocol: input.explicitProtocol } : {}), + ...(input.routingContext ? { routingContext: input.routingContext } : {}), classifier: input.protocolClassifier ?? createModelProtocolClassifier(input.modelProvider), requirementExtractor: input.analysisRequirementExtractor ?? createModelAnalysisRequirementExtractor(input.modelProvider), @@ -1486,6 +1492,7 @@ export { ProtocolRouter } from "./protocol/protocol-router.js"; export { ProtocolRuntime } from "./protocol/protocol-runtime.js"; export { createModelProtocolClassifier } from "./protocol/model-protocol-classifier.js"; export { createRunProtocolBoundary } from "./protocol/run-protocol-boundary.js"; +export type { RoutingContext } from "./protocol/run-protocol-boundary.js"; export type * from "./protocol/run-protocol-boundary.js"; export { createGeneralTaskProtocol } from "./protocol/protocols/general-task.js"; export { createDataAnalysisProtocol } from "./protocol/protocols/data-analysis.js"; diff --git a/packages/agent-runtime/src/protocol/model-protocol-classifier.test.ts b/packages/agent-runtime/src/protocol/model-protocol-classifier.test.ts index df5cae46..26dcb122 100644 --- a/packages/agent-runtime/src/protocol/model-protocol-classifier.test.ts +++ b/packages/agent-runtime/src/protocol/model-protocol-classifier.test.ts @@ -19,7 +19,31 @@ describe("createProtocolClassificationPrompt", () => { expect(prompt).toContain("data-analysis@1"); expect(prompt).toContain("比较订单趋势"); expect(prompt).toContain("只能选择候选集合中的协议"); - expect(prompt).toContain('"reasonCodes":["ANALYTIC_INTENT"]'); + expect(prompt).toContain("INHERITED_PRIOR_PROTOCOL"); + }); + + it("surfaces routing context fields so the classifier can inherit prior intent", () => { + const prompt = createProtocolClassificationPrompt({ + candidates: [ + { protocolId: "general-task", protocolVersion: "1" }, + { protocolId: "data-analysis", protocolVersion: "1" } + ], + value: { + userText: "再次尝试", + previousQuery: "帮我分析当前数据", + previousProtocol: { protocolId: "data-analysis", protocolVersion: "1", terminalStatus: "completed" }, + selectedSkillIds: ["data-analysis"], + selectedDatasourceId: "orders-db" + } + }); + + expect(prompt).toContain("当前用户查询: 再次尝试"); + expect(prompt).toContain("上一轮用户查询: 帮我分析当前数据"); + expect(prompt).toContain("上一轮协议: data-analysis 终态=completed"); + expect(prompt).toContain("已选 skill: data-analysis"); + expect(prompt).toContain("已选数据源: orders-db"); + expect(prompt).toContain("弱后续"); + expect(prompt).toContain("延续 data-analysis"); }); it("strictly parses fenced JSON returned by compatible models", () => { diff --git a/packages/agent-runtime/src/protocol/model-protocol-classifier.ts b/packages/agent-runtime/src/protocol/model-protocol-classifier.ts index cd6d7cbf..3af1b9a3 100644 --- a/packages/agent-runtime/src/protocol/model-protocol-classifier.ts +++ b/packages/agent-runtime/src/protocol/model-protocol-classifier.ts @@ -16,17 +16,45 @@ const classificationSchema = z.object({ export const createProtocolClassificationPrompt = (input: { candidates: ProtocolIdentity[]; value: unknown; -}): string => [ - "你是协议路由分类器,不是执行任务的 Agent。", - "只能选择候选集合中的协议,不得发明协议或调用工具。", - "data-analysis 用于需要数据源、schema、SQL、指标、统计或数据结论的任务。", - "general-task 用于日常问答、解释、总结、文件、知识检索和普通协作任务。", - `候选集合: ${input.candidates.map((item) => `${item.protocolId}@${item.protocolVersion}`).join(", ")}`, - `分类输入: ${JSON.stringify(input.value)}`, - "只返回一个 JSON 对象,不要 Markdown。字段为 protocolId、protocolVersion、confidence、reasonCodes。", - '格式示例: {"protocolId":"data-analysis","protocolVersion":"1","confidence":0.91,"reasonCodes":["ANALYTIC_INTENT"]}', - "reasonCodes 只能使用大写英文与下划线。" -].join("\n"); +}): string => { + const value = isRecord(input.value) ? input.value : {}; + const fields: string[] = []; + if (typeof value.userText === "string") { + fields.push(`当前用户查询: ${value.userText}`); + } + if (typeof value.previousQuery === "string") { + fields.push(`上一轮用户查询: ${value.previousQuery}`); + } + if (isRecord(value.previousProtocol)) { + const prev = value.previousProtocol; + const parts: string[] = []; + if (typeof prev.protocolId === "string") parts.push(prev.protocolId); + if (typeof prev.terminalStatus === "string") parts.push(`终态=${prev.terminalStatus}`); + if (parts.length > 0) fields.push(`上一轮协议: ${parts.join(" ")}`); + } + if (Array.isArray(value.selectedSkillIds) && value.selectedSkillIds.length > 0) { + fields.push(`已选 skill: ${value.selectedSkillIds.join(", ")}`); + } + if (typeof value.selectedDatasourceId === "string") { + fields.push(`已选数据源: ${value.selectedDatasourceId}`); + } + return [ + "你是协议路由分类器,不是执行任务的 Agent。", + "只能选择候选集合中的协议,不得发明协议或调用工具。", + "data-analysis 用于需要数据源、schema、SQL、指标、统计或数据结论的任务。", + "general-task 用于日常问答、解释、总结、文件、知识检索和普通协作任务。", + "当当前查询是弱后续(如 继续、重试、再试、再来一次、try again 等)且上一轮使用的是 data-analysis 时,", + "应倾向于延续 data-analysis,除非用户明确切换了任务主题。", + `候选集合: ${input.candidates.map((item) => `${item.protocolId}@${item.protocolVersion}`).join(", ")}`, + ...(fields.length > 0 ? ["分类输入:", ...fields] : [`分类输入: ${JSON.stringify(input.value)}`]), + "只返回一个 JSON 对象,不要 Markdown。字段为 protocolId、protocolVersion、confidence、reasonCodes。", + '格式示例: {"protocolId":"data-analysis","protocolVersion":"1","confidence":0.91,"reasonCodes":["INHERITED_PRIOR_PROTOCOL"]}', + "reasonCodes 只能使用大写英文与下划线。" + ].join("\n"); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); /** Parse model text into the strict classifier contract without trusting provider-specific JSON modes. */ export const parseProtocolClassificationText = (text: string): z.infer => { diff --git a/packages/agent-runtime/src/protocol/run-protocol-boundary.test.ts b/packages/agent-runtime/src/protocol/run-protocol-boundary.test.ts index 19e72d49..64390107 100644 --- a/packages/agent-runtime/src/protocol/run-protocol-boundary.test.ts +++ b/packages/agent-runtime/src/protocol/run-protocol-boundary.test.ts @@ -1137,6 +1137,108 @@ describe("createRunProtocolBoundary", () => { }); expect(boundary.protocolRuntime.getState("run-agent-handoff").phase).toBe("query_planning"); }); + + it("routes a weak follow-up to data-analysis when routingContext carries a prior data-analysis protocol", async () => { + const classificationInputs: unknown[] = []; + const boundary = await createRunProtocolBoundary({ + runId: "run-follow-up-inherit", + userInput: "再次尝试", + authorizedProtocolIds: ["general-task", "data-analysis"], + initialContextPackageRef: { packageId: "context-follow-up", revision: 0 }, + tools: {}, + routingContext: { + previousQuery: "帮我分析当前数据", + previousProtocol: { protocolId: "data-analysis", protocolVersion: "1", terminalStatus: "completed" }, + selectedSkillIds: ["data-analysis"], + selectedDatasourceId: "orders-db" + }, + // Stub classifier simulates the LLM picking data-analysis when it sees the + // prior protocol in classificationInput. A real LLM is expected to do the same. + classifier: async ({ value }) => { + classificationInputs.push(value); + const record = value as Record; + const previous = record.previousProtocol as { protocolId: string } | undefined; + return { + protocolId: previous?.protocolId === "data-analysis" ? "data-analysis" : "general-task", + protocolVersion: "1", + confidence: previous?.protocolId === "data-analysis" ? 0.9 : 0.4, + reasonCodes: previous?.protocolId === "data-analysis" ? ["INHERITED_PRIOR_PROTOCOL"] : ["WEAK_INTENT"] + }; + }, + projectContext: () => ({ packageId: "context-follow-up", revision: 0 }) + }); + + expect(boundary.route.definition.id).toBe("data-analysis"); + expect(boundary.route.source).toBe("classifier"); + expect(boundary.route.reasonCodes).toEqual(["INHERITED_PRIOR_PROTOCOL"]); + // The classifier must receive the routing context fields, not just the bare follow-up text. + expect(classificationInputs).toEqual([expect.objectContaining({ + userText: "再次尝试", + previousQuery: "帮我分析当前数据", + previousProtocol: expect.objectContaining({ protocolId: "data-analysis", terminalStatus: "completed" }), + selectedSkillIds: ["data-analysis"], + selectedDatasourceId: "orders-db" + })]); + }); + + it("falls back to general-task for a weak follow-up when no routingContext is provided", async () => { + const boundary = await createRunProtocolBoundary({ + runId: "run-follow-up-no-context", + userInput: "再次尝试", + authorizedProtocolIds: ["general-task", "data-analysis"], + initialContextPackageRef: { packageId: "context-follow-up-none", revision: 0 }, + tools: {}, + // Without session context the classifier sees only the ambiguous text → low confidence. + classifier: async () => ({ + protocolId: "data-analysis", + protocolVersion: "1", + confidence: 0.4, + reasonCodes: ["WEAK_INTENT"] + }), + projectContext: () => ({ packageId: "context-follow-up-none", revision: 0 }) + }); + + expect(boundary.route.definition.id).toBe("general-task"); + expect(boundary.route.source).toBe("default"); + expect(boundary.route.warnings).toEqual(["PROTOCOL_CLASSIFICATION_LOW_CONFIDENCE"]); + }); + + it("emits a consistency warning when general-task is selected but data tools are exposed", async () => { + const events: ProtocolEvent[] = []; + await createRunProtocolBoundary({ + runId: "run-consistency-mismatch", + userInput: "再次尝试", + authorizedProtocolIds: ["general-task", "data-analysis"], + explicitProtocol: { protocolId: "general-task", protocolVersion: "1" }, + initialContextPackageRef: { packageId: "context-consistency", revision: 0 }, + tools: { inspect_schema: { execute: async () => ({}) } }, + routingContext: { selectedDatasourceId: "orders-db" }, + runtimeOptions: { onEvent: (event) => events.push(event) }, + projectContext: () => ({ packageId: "context-consistency", revision: 0 }) + }); + + expect(events).toContainEqual(expect.objectContaining({ + type: "protocol.route.consistency.warning", + payload: { code: "PROTOCOL_TOOL_POLICY_MISMATCH", reason: expect.stringContaining("general-task rejects every data action") } + })); + }); + + it("does not emit a consistency warning when no datasource is selected", async () => { + const events: ProtocolEvent[] = []; + const boundary = await createRunProtocolBoundary({ + runId: "run-consistency-no-datasource", + userInput: "再次尝试", + authorizedProtocolIds: ["general-task", "data-analysis"], + explicitProtocol: { protocolId: "general-task", protocolVersion: "1" }, + initialContextPackageRef: { packageId: "context-consistency-none", revision: 0 }, + tools: { inspect_schema: { execute: async () => ({}) } }, + runtimeOptions: { onEvent: (event) => events.push(event) }, + projectContext: () => ({ packageId: "context-consistency-none", revision: 0 }) + }); + + expect(events.some((event) => event.type === "protocol.route.consistency.warning")).toBe(false); + expect(boundary.route.warnings).not.toContain("PROTOCOL_TOOL_POLICY_MISMATCH"); + }); }); const liveSemanticProvider = (): { resolve(request: SemanticRequest): Promise } => ({ diff --git a/packages/agent-runtime/src/protocol/run-protocol-boundary.ts b/packages/agent-runtime/src/protocol/run-protocol-boundary.ts index 601b0672..6a5c7751 100644 --- a/packages/agent-runtime/src/protocol/run-protocol-boundary.ts +++ b/packages/agent-runtime/src/protocol/run-protocol-boundary.ts @@ -65,6 +65,26 @@ export type CreateRunProtocolBoundaryInput = { semanticRequest?: Omit; requirementExtractor?: AnalysisRequirementExtractor; analysisContractGrounder?: AnalysisContractGrounder; + /** Compact routing context for follow-up intents. Lets the protocol classifier + * see the previous round's query, protocol, and selected resources so a short + * follow-up such as "再次尝试" can inherit the prior data-analysis intent. */ + routingContext?: RoutingContext; +}; + +export type RoutingContext = { + /** The previous round's meaningful user query, if any. */ + previousQuery?: string; + /** The previous round's resolved protocol and its terminal status, if any. */ + previousProtocol?: { + protocolId: string; + protocolVersion: string; + /** Terminal completion status, e.g. completed/degraded/partial/failed. */ + terminalStatus?: string; + }; + /** Skill IDs selected for this run. */ + selectedSkillIds?: string[]; + /** Datasource ID selected for this run. */ + selectedDatasourceId?: string; }; export type RunProtocolBoundary = { @@ -128,7 +148,7 @@ export const createRunProtocolBoundary = async ( reasonCode: "ANALYTIC_INTENT" }] : [], - classificationInput: { userText: input.userInput } + classificationInput: buildClassificationInput(input.userInput, input.routingContext) }); } catch (error) { input.runtimeOptions?.onEvent?.({ @@ -143,6 +163,27 @@ export const createRunProtocolBoundary = async ( }); throw error; } + // Consistency guard (issue: "a run cannot expose a data-analysis tool policy + // while being governed by a protocol that rejects every data action"). When the + // resolved protocol is general-task but the run exposes data tools and a selected + // datasource, emit a warning so the agent can self-correct via protocol_handoff. + if (route.definition.id === "general-task" && exposesDataToolPolicy(input) && input.routingContext?.selectedDatasourceId) { + const warningCode = "PROTOCOL_TOOL_POLICY_MISMATCH"; + route = { + ...route, + warnings: [...route.warnings, warningCode] + }; + input.runtimeOptions?.onEvent?.({ + eventId: `${input.runId}:segment:1:0:protocol.route.consistency.warning`, + type: "protocol.route.consistency.warning", + runId: input.runId, + segmentId: `${input.runId}:segment:1`, + protocolId: route.definition.id, + protocolVersion: route.definition.version, + revision: 0, + payload: { code: warningCode, reason: "general-task rejects every data action while data tools are exposed" } + }); + } let activeProtocolId = route.definition.id; const reduceAction = (state: unknown, actionName: string, result: unknown): unknown => activeProtocolId === "data-analysis" @@ -505,6 +546,42 @@ const stripLeadingSqlComments = (sql: string): string => { const analyticIntent = (userInput: string): boolean => /\b(?:sql|query|metric|analytics?|statistics?)\b|分析|统计|指标|数据|销售额/iu.test(userInput); +const DATA_TOOL_NAMES = new Set(["list_data_sources", "inspect_schema", "preview_table", "run_sql_readonly"]); + +/** Whether the run exposes any data-analysis tool in its selected tool set. */ +const exposesDataToolPolicy = (input: CreateRunProtocolBoundaryInput): boolean => + Object.keys(input.tools).some((name) => DATA_TOOL_NAMES.has(name)); + +/** Build the compact routing context the classifier sees alongside the current query. */ +const buildClassificationInput = ( + userInput: string, + routingContext?: RoutingContext +): Record => { + const value: Record = { userText: userInput }; + if (!routingContext) { + return value; + } + if (routingContext.previousQuery) { + value.previousQuery = routingContext.previousQuery; + } + if (routingContext.previousProtocol) { + value.previousProtocol = { + protocolId: routingContext.previousProtocol.protocolId, + protocolVersion: routingContext.previousProtocol.protocolVersion, + ...(routingContext.previousProtocol.terminalStatus + ? { terminalStatus: routingContext.previousProtocol.terminalStatus } + : {}) + }; + } + if (routingContext.selectedSkillIds?.length) { + value.selectedSkillIds = [...routingContext.selectedSkillIds]; + } + if (routingContext.selectedDatasourceId) { + value.selectedDatasourceId = routingContext.selectedDatasourceId; + } + return value; +}; + const allowAction = (): ProtocolGuardResult => ({ allowed: true }); const dataAnalysisPreparatoryActions = (input: { diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts index 25897284..17346d37 100644 --- a/packages/metadata/src/index.ts +++ b/packages/metadata/src/index.ts @@ -1701,6 +1701,28 @@ export class RunRepository { return mapRunRow(this.db.prepare(sql).get(...params)); } + /** Most recent terminal run in the session (completed/failed/canceled), excluding + * the current run. Used by the protocol router to inherit the prior round's intent + * for short follow-ups such as "再次尝试" ("try again"). */ + findPreviousRunBySession(input: { + exclude_run_id: string; + session_id: string; + user_id: string; + }): Optional { + return mapRunRow( + this.db.prepare(` + SELECT * + FROM runs + WHERE user_id = ? + AND session_id = ? + AND status IN ('completed', 'failed', 'canceled') + AND id <> ? + ORDER BY started_at DESC + LIMIT 1 + `).get(input.user_id, input.session_id, input.exclude_run_id) + ); + } + listByStatuses(input: { statuses: Array; limit?: number }): RunRecord[] { if (input.statuses.length === 0) { return []; diff --git a/packages/metadata/src/run-previous-by-session.test.ts b/packages/metadata/src/run-previous-by-session.test.ts new file mode 100644 index 00000000..68617f0c --- /dev/null +++ b/packages/metadata/src/run-previous-by-session.test.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { createMetadataStore, createVerifiedTestIdentity } from "./index.js"; + +describe("RunRepository.findPreviousRunBySession", () => { + it("returns the most recent terminal run in the session excluding the current run", () => { + const root = mkdtempSync(join(tmpdir(), "run-previous-by-session-")); + const metadata = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); + const { userId } = createVerifiedTestIdentity(metadata); + try { + metadata.sessions.create({ user_id: userId, id: "session-1", title: "Prev" }); + metadata.runs.create({ + user_id: userId, id: "run-1", session_id: "session-1", + user_input: "帮我分析当前数据", status: "completed" + }); + metadata.runs.create({ + user_id: userId, id: "run-2", session_id: "session-1", + user_input: "再次尝试", status: "running" + }); + + const previous = metadata.runs.findPreviousRunBySession({ + user_id: userId, session_id: "session-1", exclude_run_id: "run-2" + }); + + expect(previous?.id).toBe("run-1"); + expect(previous?.user_input).toBe("帮我分析当前数据"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("returns undefined when the only run in the session is the current run", () => { + const root = mkdtempSync(join(tmpdir(), "run-previous-none-")); + const metadata = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); + const { userId } = createVerifiedTestIdentity(metadata); + try { + metadata.sessions.create({ user_id: userId, id: "session-1", title: "Solo" }); + metadata.runs.create({ + user_id: userId, id: "run-1", session_id: "session-1", + user_input: "hello", status: "completed" + }); + + const previous = metadata.runs.findPreviousRunBySession({ + user_id: userId, session_id: "session-1", exclude_run_id: "run-1" + }); + + expect(previous).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("ignores non-terminal runs when selecting the previous run", () => { + const root = mkdtempSync(join(tmpdir(), "run-previous-terminal-")); + const metadata = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); + const { userId } = createVerifiedTestIdentity(metadata); + try { + metadata.sessions.create({ user_id: userId, id: "session-1", title: "Mixed" }); + // A suspended (non-terminal) run started earlier than the completed one. + metadata.runs.create({ + user_id: userId, id: "run-1", session_id: "session-1", + user_input: "suspended earlier", status: "suspended" + }); + metadata.runs.create({ + user_id: userId, id: "run-2", session_id: "session-1", + user_input: "completed later", status: "completed" + }); + metadata.runs.create({ + user_id: userId, id: "run-3", session_id: "session-1", + user_input: "current", status: "running" + }); + + const previous = metadata.runs.findPreviousRunBySession({ + user_id: userId, session_id: "session-1", exclude_run_id: "run-3" + }); + + // Should pick run-2 (completed), not run-1 (suspended, excluded by status filter). + expect(previous?.id).toBe("run-2"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});