feat: upgrade investigation and presentation runtime
Generic Container CI/CD / test-build-publish (push) Successful in 1m6s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m7s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent 3b52a0ddab
commit 6336157ff4
104 changed files with 6541 additions and 961 deletions
+85 -3
View File
@@ -12,6 +12,8 @@ import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
import type { ResponseInput } from "../../src/chat/response-inputs.js";
import { InteractionRequestStore } from "../../src/sessions/interaction-request-store.js";
import { INTERACTION_VERSION } from "../../src/chat/interaction-request-contract.js";
const http = globalThis.fetch;
let root: string;
@@ -22,12 +24,19 @@ let prompted: string[];
let pendingInputs: ResponseInput[];
let aborts: number;
let gate: Promise<void> | undefined;
let runtimeEvents: AsyncIterable<unknown> | undefined;
let release: () => void = () => {};
let bridge: ChatSessionBridge;
let states: SessionUiStateStore;
let interactions: InteractionRequestStore;
let nativeState: "missing" | "enqueued" | "delivered";
let completedResult: string | undefined;
let enqueueResumes: boolean[];
const project = "10000000-0000-4000-8000-000000000001";
beforeEach(async () => {
fail = false; prompted = []; pendingInputs = []; aborts = 0; gate = undefined;
runtimeEvents = undefined;
nativeState = "missing"; completedResult = undefined; enqueueResumes = [];
root = await mkdtemp(join(tmpdir(), "tjwater-responses-"));
globalThis.fetch = (async (_input, init) => Response.json({
user_id: new Headers(init?.headers).get("authorization") === "Bearer owner" ? "owner" : "other",
@@ -36,6 +45,7 @@ beforeEach(async () => {
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
states = new SessionUiStateStore(join(root, "ui"), workspaces);
interactions = new InteractionRequestStore(workspaces);
await sessions.initialize();
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
await sessions.ensure(context);
@@ -51,14 +61,16 @@ beforeEach(async () => {
pendingInputs = pendingInputs.filter((input) => input.id !== id);
return { ...item, status: "cancelled" };
},
enqueueUserInput: async (_sessionId: string, input: ResponseInput) => { pendingInputs.push({ ...input }); },
enqueueUserInput: async (_sessionId: string, input: ResponseInput, options?: { resume?: boolean }) => { pendingInputs.push({ ...input }); enqueueResumes.push(options?.resume ?? true); },
agentMessageState: async () => nativeState,
readInteractionResult: async () => { if (completedResult === undefined) throw new Error("No completed result"); return completedResult; },
updatePendingUserInput: async (_sessionId: string, id: string, delivery: "steer" | "queue") => {
const item = pendingInputs.find((input) => input.id === id);
if (!item) throw Object.assign(new Error("Consumed"), { status: 409 });
item.delivery = delivery;
return { ...item };
},
subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() {
subscribeEvents: async () => runtimeEvents ?? ({ async *[Symbol.asyncIterator]() {
if (gate) await gate;
yield { type: "message.part.updated", properties: { sessionID: "owned", part: { id: "answer", type: "text", text: "", sessionID: "owned", messageID: "native-answer" } } };
yield { type: "message.part.delta", properties: { sessionID: "owned", partID: "answer", field: "text", delta: "压力待核验" } };
@@ -71,7 +83,7 @@ beforeEach(async () => {
const app = express(); app.use(express.json());
bridge = new ChatSessionBridge(runtime);
app.use("/api/v1", buildChatRouter({
runtime, sessionBridge: bridge, sessionMetadataStore: sessions, sessionUiStateStore: states,
runtime, sessionBridge: bridge, sessionMetadataStore: sessions, sessionUiStateStore: states, interactionRequestStore: interactions,
sessionTranscriptStore: { getRecentTurns: async () => [] } as never,
memoryStore: { buildPromptSnapshot: async () => "" } as never,
learningOrchestrator: { onTurnCompleted: async () => {} } as never,
@@ -95,6 +107,43 @@ const call = (body: unknown, token = "owner", path = "/api/v1/responses") => htt
method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Project-Id": project, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(5000),
});
it("admits steer and queue while a host question is waiting without starting unowned inference", async () => {
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), sessionId: "owned" };
const question = await interactions.create(context, { message_id: "question", turn_id: "turn", call_id: "ask", questions: [{ question_id: "scope", title: "范围", body: "核查哪里?", options: [], mode: "single", required: true, allow_custom: true }] });
for (const [i, delivery] of ["steer", "queue"].entries()) {
const result = await call({ input: `补充 ${i}`, conversation: "owned", tjwater: { delivery, input_id: `msg_${String(i).repeat(32)}` } });
expect(result.status).toBe(202);
expect(await result.json()).toMatchObject({ delivery, status: "pending" });
}
expect(pendingInputs).toHaveLength(2);
expect(enqueueResumes).toEqual([false, false]);
expect(prompted).toEqual([]);
expect((await interactions.get(context, question.interaction_id))?.status).toBe("pending");
expect((await call({ input: "foreign", conversation: "owned", tjwater: { delivery: "queue", input_id: `msg_${"f".repeat(32)}` } }, "other")).status).toBe(404);
expect(pendingInputs).toHaveLength(2);
});
for (const recovery of ["list", "retry"] as const) it(`reconciles a blocked acknowledgement through ${recovery} before admitting the next message`, async () => {
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), sessionId: "owned" };
const question = await interactions.create(context, { message_id: "question", turn_id: "turn", call_id: "ask", questions: [{ question_id: "scope", title: "范围", body: "核查哪里?", options: [], mode: "single", required: true, allow_custom: true }] });
await interactions.reply(context, question.interaction_id, { version: INTERACTION_VERSION, request_id: "reply", expected_revision: 0, answers: [{ question_id: "scope", selected_option_ids: [], custom_text: "当前片区" }] });
await interactions.updateDelivery(context, question.interaction_id, { expected_revision: 0, status: "blocked", error_code: "CONTINUATION_UNCONFIRMED" });
nativeState = "delivered"; completedResult = "已完成核查。";
if (recovery === "retry") {
const replay = await call({ input: "重试继续", conversation: "owned", tjwater: { interaction_id: question.interaction_id } });
expect(replay.status).toBe(200);
expect(JSON.stringify(await replay.json())).toContain("已完成核查。");
}
const list = await http(`${base}/api/v1/session/owned/interactions`, { headers: { Authorization: "Bearer owner", "X-Project-Id": project } });
expect((await list.json()).items[0].delivery.status).toBe("delivered");
expect(await interactions.getBlocking(context)).toBeNull();
expect(prompted).toEqual([]);
const response = await call({ input: "继续检查压力", conversation: "owned" });
expect(response.status).toBe(200);
expect((await response.json()).status).toBe("completed");
expect(prompted).toHaveLength(1);
});
it("serves Responses SSE through the real HTTP route and preserves the owned conversation", async () => {
const response = await call({ input: [{ role: "user", content: [{ type: "input_text", text: "核查压力" }] }], conversation: "owned", stream: true });
const text = await response.text();
@@ -108,6 +157,39 @@ it("serves Responses SSE through the real HTTP route and preserves the owned con
expect(prompted).toHaveLength(1);
expect(prompted[0]).toContain("核查压力");
});
it("delivers narration over HTTP while a tool is still waiting", async () => {
gate = new Promise<void>((resolve) => { release = resolve; });
runtimeEvents = { async *[Symbol.asyncIterator]() {
yield { type: "message.part.updated", properties: { sessionID: "owned", part: { id: "text", type: "text", text: "" } } };
yield { type: "message.part.delta", properties: { sessionID: "owned", partID: "text", field: "text", delta: "正在核查。" } };
yield { type: "message.part.updated", properties: { sessionID: "owned", part: {
id: "query", type: "tool", tool: "mcp_fixture_query", callID: "query", state: { status: "running", input: {}, time: { start: 1 } },
} } };
await gate;
yield { type: "session.idle", properties: { sessionID: "owned" } };
} };
const response = await call({ input: "核查压力", conversation: "owned", stream: true });
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let received = "";
try {
while (!received.includes("event: response.output_text.delta")) {
const next = await reader.read();
if (next.done) break;
received += decoder.decode(next.value, { stream: true });
}
expect(received).toContain('"delta":"正在核查。"');
expect(received).not.toContain("event: response.completed");
} finally { release(); }
while (true) {
const next = await reader.read();
if (next.done) break;
received += decoder.decode(next.value, { stream: true });
}
expect(received).toContain("event: response.completed");
});
it("returns a Response object when streaming is omitted", async () => {
const response = await call({ input: "核查压力", conversation: { id: "owned" } });
expect(await response.json()).toMatchObject({ object: "response", status: "completed", conversation: { id: "owned" }, output: [{ type: "message", content: [{ type: "output_text", text: "压力待核验" }] }] });