feat: upgrade investigation and presentation runtime
This commit is contained in:
@@ -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: "压力待核验" }] }] });
|
||||
|
||||
Reference in New Issue
Block a user