feat: establish tool call state machine
This commit is contained in:
@@ -50,6 +50,12 @@ macOS 的 `src-tauri/Info.plist` 为当前 Tailscale / 私网 HTTP AppServer 配
|
|||||||
|
|
||||||
M0-01~M0-08 已完成。Mac 经 Linux Tailscale Web Host `http://100.121.118.116:1420` 完成 M0-08 总验收:登录、历史追平、本地回显、Agent 回复、真实 Markdown(标题、加粗、行内代码、围栏 TypeScript 代码块)和刷新重新登录恢复均正常;用户与 Agent 消息不重复,presence 最终为“已同步,等待消息”。同时 `npm test` 的 2 个文件 / 8 个 Runtime 测试与 `npm run build` 均通过。
|
M0-01~M0-08 已完成。Mac 经 Linux Tailscale Web Host `http://100.121.118.116:1420` 完成 M0-08 总验收:登录、历史追平、本地回显、Agent 回复、真实 Markdown(标题、加粗、行内代码、围栏 TypeScript 代码块)和刷新重新登录恢复均正常;用户与 Agent 消息不重复,presence 最终为“已同步,等待消息”。同时 `npm test` 的 2 个文件 / 8 个 Runtime 测试与 `npm run build` 均通过。
|
||||||
|
|
||||||
|
### M1-01 Tool Call 状态机(已完成)
|
||||||
|
|
||||||
|
`src/protocol.ts` 现可严格解码 `lineup.v1.tool.call`、`lineup.v1.tool.result`、`lineup.v1.tool.cancel`,只接受 `choice`、`confirm`、`input` 三种声明性请求。`src/runtime/tool-call-state.ts` 是独立于 UI、Storage、Transport、Tauri 与 Capability 的纯状态机;它以 `call_id` 为唯一关联键,只允许 `pending → submitted → completed / failed / cancelled / expired`,拒绝重复调用及非法状态跃迁。Interaction Kernel 仅投影这些状态,且只有已提交的 call 能接受 result。
|
||||||
|
|
||||||
|
这不是可用的交互组件:M1-01 不渲染选项、不提供按钮/表单、不发送 tool result,也不修改 Hermes Adapter。可信 ActionGroup 留给 M1-02,Confirm/Input/Form 留给 M1-03。`npm test` 当前为 3 个文件 / 14 个测试通过,`npm run build` 通过。
|
||||||
|
|
||||||
### 当前结构债务(M0 的改造对象)
|
### 当前结构债务(M0 的改造对象)
|
||||||
|
|
||||||
`src/main.ts` 目前包含 App Shell、页面 DOM、会话 cursor、同步循环与 Runtime 编排;M0-05 已将登录、发送与同步 HTTP `fetch` 迁入 `src/runtime/transport-adapter.ts`,M0-06 已将所有可信 DOM renderer 迁入 `src/runtime/trusted-dom-renderers.ts`。在 M0 准入前不新增 Surface 或 Capability 功能。
|
`src/main.ts` 目前包含 App Shell、页面 DOM、会话 cursor、同步循环与 Runtime 编排;M0-05 已将登录、发送与同步 HTTP `fetch` 迁入 `src/runtime/transport-adapter.ts`,M0-06 已将所有可信 DOM renderer 迁入 `src/runtime/trusted-dom-renderers.ts`。在 M0 准入前不新增 Surface 或 Capability 功能。
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ rendererRegistry.register("markdown", (item, context) => context.renderMarkdown(
|
|||||||
rendererRegistry.register("agent-status", (item, context) => context.renderAgentStatus(item));
|
rendererRegistry.register("agent-status", (item, context) => context.renderAgentStatus(item));
|
||||||
rendererRegistry.register("progress", (item, context) => context.renderProgress(item));
|
rendererRegistry.register("progress", (item, context) => context.renderProgress(item));
|
||||||
rendererRegistry.register("error", (item, context) => context.renderError(item));
|
rendererRegistry.register("error", (item, context) => context.renderError(item));
|
||||||
|
rendererRegistry.register("tool-call", (item, context) => context.renderToolCall(item));
|
||||||
|
rendererRegistry.register("tool-result", (item, context) => context.renderToolResult(item));
|
||||||
|
rendererRegistry.register("tool-cancel", (item, context) => context.renderToolCancel(item));
|
||||||
rendererRegistry.register("surface", (item, context) => context.renderSurface(item));
|
rendererRegistry.register("surface", (item, context) => context.renderSurface(item));
|
||||||
rendererRegistry.register("app-call", (item, context) => context.renderAppCall(item));
|
rendererRegistry.register("app-call", (item, context) => context.renderAppCall(item));
|
||||||
rendererRegistry.register("fallback", (item, context) => context.renderFallback(item));
|
rendererRegistry.register("fallback", (item, context) => context.renderFallback(item));
|
||||||
|
|||||||
@@ -37,6 +37,32 @@ export type DeliveryState =
|
|||||||
| { status: "delivered"; updated_at: string; transport_id?: string }
|
| { status: "delivered"; updated_at: string; transport_id?: string }
|
||||||
| { status: "failed"; updated_at: string; error: string; retryable: boolean };
|
| { status: "failed"; updated_at: string; error: string; retryable: boolean };
|
||||||
|
|
||||||
|
/** The only standard interaction requests admitted during Milestone 1. */
|
||||||
|
export type ToolCallKind = "choice" | "confirm" | "input";
|
||||||
|
|
||||||
|
export type ToolCallRequest = {
|
||||||
|
call_id: string;
|
||||||
|
tool: ToolCallKind;
|
||||||
|
title: string;
|
||||||
|
prompt: string;
|
||||||
|
expires_at?: string;
|
||||||
|
data: JsonObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired";
|
||||||
|
|
||||||
|
export type ToolCallResult = {
|
||||||
|
call_id: string;
|
||||||
|
status: ToolCallFinalStatus;
|
||||||
|
result: JsonObject;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolCallCancel = {
|
||||||
|
call_id: string;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A user-originated action awaiting delivery through the Transport Adapter.
|
* A user-originated action awaiting delivery through the Transport Adapter.
|
||||||
* It is data only: the Store owns retries and the Kernel owns state changes.
|
* It is data only: the Store owns retries and the Kernel owns state changes.
|
||||||
@@ -105,6 +131,9 @@ export type ConversationItem =
|
|||||||
| (ConversationItemBase & { kind: "agent-status"; status: string; detail: string })
|
| (ConversationItemBase & { kind: "agent-status"; status: string; detail: string })
|
||||||
| (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string })
|
| (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string })
|
||||||
| (ConversationItemBase & { kind: "error"; message: string; code?: string })
|
| (ConversationItemBase & { kind: "error"; message: string; code?: string })
|
||||||
|
| (ConversationItemBase & { kind: "tool-call"; call: ToolCallRequest })
|
||||||
|
| (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult })
|
||||||
|
| (ConversationItemBase & { kind: "tool-cancel"; cancel: ToolCallCancel })
|
||||||
| (ConversationItemBase & { kind: "surface"; envelope: Envelope })
|
| (ConversationItemBase & { kind: "surface"; envelope: Envelope })
|
||||||
| (ConversationItemBase & { kind: "app-call"; envelope: Envelope })
|
| (ConversationItemBase & { kind: "app-call"; envelope: Envelope })
|
||||||
| (ConversationItemBase & { kind: "fallback"; reason: ProtocolFallbackCode; detail: string; envelope?: Envelope });
|
| (ConversationItemBase & { kind: "fallback"; reason: ProtocolFallbackCode; detail: string; envelope?: Envelope });
|
||||||
@@ -119,6 +148,9 @@ const SUPPORTED_TYPES = new Set([
|
|||||||
"lineup.v1.agent.status",
|
"lineup.v1.agent.status",
|
||||||
"lineup.v1.agent.progress",
|
"lineup.v1.agent.progress",
|
||||||
"lineup.v1.error",
|
"lineup.v1.error",
|
||||||
|
"lineup.v1.tool.call",
|
||||||
|
"lineup.v1.tool.result",
|
||||||
|
"lineup.v1.tool.cancel",
|
||||||
"lineup.v1.ui.open",
|
"lineup.v1.ui.open",
|
||||||
"lineup.v1.ui.patch",
|
"lineup.v1.ui.patch",
|
||||||
"lineup.v1.ui.close",
|
"lineup.v1.ui.close",
|
||||||
@@ -233,6 +265,44 @@ function validAppCallPayload(payload: JsonObject): boolean {
|
|||||||
return Boolean(identifier(payload.call_id) && identifier(payload.capability) && object(payload.arguments));
|
return Boolean(identifier(payload.call_id) && identifier(payload.capability) && object(payload.arguments));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOOL_KINDS = new Set<ToolCallKind>(["choice", "confirm", "input"]);
|
||||||
|
const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]);
|
||||||
|
|
||||||
|
function optionalTimestamp(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
|
||||||
|
const callID = identifier(payload.call_id);
|
||||||
|
const tool = text(payload.tool) as ToolCallKind;
|
||||||
|
if (!callID || !TOOL_KINDS.has(tool)) return undefined;
|
||||||
|
const expiresAt = optionalTimestamp(payload.expires_at);
|
||||||
|
if (payload.expires_at !== undefined && !expiresAt) return undefined;
|
||||||
|
const data = payload.data === undefined ? {} : object(payload.data);
|
||||||
|
if (!data) return undefined;
|
||||||
|
return {
|
||||||
|
call_id: callID,
|
||||||
|
tool,
|
||||||
|
title: text(payload.title),
|
||||||
|
prompt: text(payload.prompt),
|
||||||
|
...(expiresAt ? { expires_at: expiresAt } : {}),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolResult(payload: JsonObject): ToolCallResult | undefined {
|
||||||
|
const callID = identifier(payload.call_id);
|
||||||
|
const status = text(payload.status) as ToolCallFinalStatus;
|
||||||
|
const result = payload.result === undefined ? {} : object(payload.result);
|
||||||
|
if (!callID || !TOOL_FINAL_STATUSES.has(status) || !result) return undefined;
|
||||||
|
return { call_id: callID, status, result, ...(text(payload.error) ? { error: text(payload.error) } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolCancel(payload: JsonObject): ToolCallCancel | undefined {
|
||||||
|
const callID = identifier(payload.call_id);
|
||||||
|
return callID ? { call_id: callID, reason: text(payload.reason) } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function fallbackItem(value: ProtocolFallback): ConversationItem {
|
function fallbackItem(value: ProtocolFallback): ConversationItem {
|
||||||
return {
|
return {
|
||||||
kind: "fallback",
|
kind: "fallback",
|
||||||
@@ -289,6 +359,18 @@ export function decodeConversationItem(raw: string): ConversationItem {
|
|||||||
? { kind: "error", message, ...(text(payload.code) ? { code: text(payload.code) } : {}), ...base }
|
? { kind: "error", message, ...(text(payload.code) ? { code: text(payload.code) } : {}), ...base }
|
||||||
: fallbackItem({ code: "invalid_payload", detail: "Error payload requires message.", envelope });
|
: fallbackItem({ code: "invalid_payload", detail: "Error payload requires message.", envelope });
|
||||||
}
|
}
|
||||||
|
case "lineup.v1.tool.call": {
|
||||||
|
const call = parseToolCall(payload);
|
||||||
|
return call ? { kind: "tool-call", call, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool call requires call_id, supported tool, and JSON data.", envelope });
|
||||||
|
}
|
||||||
|
case "lineup.v1.tool.result": {
|
||||||
|
const result = parseToolResult(payload);
|
||||||
|
return result ? { kind: "tool-result", result, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool result requires call_id, final status, and JSON result.", envelope });
|
||||||
|
}
|
||||||
|
case "lineup.v1.tool.cancel": {
|
||||||
|
const cancel = parseToolCancel(payload);
|
||||||
|
return cancel ? { kind: "tool-cancel", cancel, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool cancel requires call_id.", envelope });
|
||||||
|
}
|
||||||
case "lineup.v1.ui.open":
|
case "lineup.v1.ui.open":
|
||||||
case "lineup.v1.ui.patch":
|
case "lineup.v1.ui.patch":
|
||||||
case "lineup.v1.ui.close":
|
case "lineup.v1.ui.close":
|
||||||
|
|||||||
@@ -71,4 +71,59 @@ describe("InteractionKernel", () => {
|
|||||||
// The first id has expired from the bounded set and is accepted again.
|
// The first id has expired from the bounded set and is accepted again.
|
||||||
expect(kernel.ingest({ raw: envelope({ id: "evt_status_thinking" }), source: "live" }).disposition).toBe("accepted");
|
expect(kernel.ingest({ raw: envelope({ id: "evt_status_thinking" }), source: "live" }).disposition).toBe("accepted");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("projects tool calls by call_id and applies a result only after submission", () => {
|
||||||
|
const kernel = new InteractionKernel();
|
||||||
|
const call = kernel.ingest({
|
||||||
|
raw: envelope({
|
||||||
|
id: "evt_tool_call",
|
||||||
|
type: "lineup.v1.tool.call",
|
||||||
|
payload: { call_id: "call_kernel_001", tool: "choice", title: "Choose", prompt: "One", data: {} },
|
||||||
|
}),
|
||||||
|
source: "sync",
|
||||||
|
received_at: "2026-08-03T08:00:00.000Z",
|
||||||
|
});
|
||||||
|
const duplicateCallID = kernel.ingest({
|
||||||
|
raw: envelope({
|
||||||
|
id: "evt_tool_call_duplicate_id",
|
||||||
|
type: "lineup.v1.tool.call",
|
||||||
|
payload: { call_id: "call_kernel_001", tool: "choice", title: "Ignored", prompt: "Ignored", data: {} },
|
||||||
|
}),
|
||||||
|
source: "live",
|
||||||
|
});
|
||||||
|
const earlyResult = kernel.ingest({
|
||||||
|
raw: envelope({ id: "evt_tool_result_early", type: "lineup.v1.tool.result", payload: { call_id: "call_kernel_001", status: "completed", result: {} } }),
|
||||||
|
source: "live",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(call.snapshot.tool_calls).toEqual([expect.objectContaining({ call_id: "call_kernel_001", status: "pending" })]);
|
||||||
|
expect(duplicateCallID.snapshot.tool_calls).toEqual([
|
||||||
|
expect.objectContaining({ request: expect.objectContaining({ title: "Choose" }) }),
|
||||||
|
]);
|
||||||
|
expect(earlyResult.snapshot.tool_calls).toEqual([expect.objectContaining({ status: "pending" })]);
|
||||||
|
expect(kernel.submitToolCall("call_kernel_001", "2026-08-03T08:01:00.000Z")).toMatchObject({ disposition: "accepted" });
|
||||||
|
|
||||||
|
const completed = kernel.ingest({
|
||||||
|
raw: envelope({ id: "evt_tool_result_completed", type: "lineup.v1.tool.result", payload: { call_id: "call_kernel_001", status: "completed", result: { selected: "alpha" } } }),
|
||||||
|
source: "live",
|
||||||
|
});
|
||||||
|
expect(completed.snapshot.tool_calls).toEqual([
|
||||||
|
expect.objectContaining({ call_id: "call_kernel_001", status: "completed", result: { selected: "alpha" } }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps malformed tool payloads on the safe fallback path", () => {
|
||||||
|
const kernel = new InteractionKernel();
|
||||||
|
const invalidCall = kernel.ingest({
|
||||||
|
raw: envelope({ id: "evt_invalid_tool_call", type: "lineup.v1.tool.call", payload: { call_id: "call_bad", tool: "shell", data: {} } }),
|
||||||
|
source: "sync",
|
||||||
|
});
|
||||||
|
const invalidResult = kernel.ingest({
|
||||||
|
raw: envelope({ id: "evt_invalid_tool_result", type: "lineup.v1.tool.result", payload: { call_id: "call_bad", status: "submitted", result: {} } }),
|
||||||
|
source: "sync",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invalidCall.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
|
||||||
|
expect(invalidResult.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import {
|
|||||||
decodeConversationItem,
|
decodeConversationItem,
|
||||||
type Actor,
|
type Actor,
|
||||||
type ConversationItem,
|
type ConversationItem,
|
||||||
|
type JsonObject,
|
||||||
|
type ToolCallFinalStatus,
|
||||||
} from "../protocol";
|
} from "../protocol";
|
||||||
|
import { ToolCallStateMachine, type ToolCallRecord } from "./tool-call-state";
|
||||||
|
|
||||||
/** Transport metadata is intentionally opaque to the protocol and renderer. */
|
/** Transport metadata is intentionally opaque to the protocol and renderer. */
|
||||||
export type IncomingMessage = {
|
export type IncomingMessage = {
|
||||||
@@ -23,6 +26,7 @@ export type AgentPresence = {
|
|||||||
export type KernelSnapshot = {
|
export type KernelSnapshot = {
|
||||||
seen_envelope_ids: number;
|
seen_envelope_ids: number;
|
||||||
agent_presence: readonly AgentPresence[];
|
agent_presence: readonly AgentPresence[];
|
||||||
|
tool_calls: readonly ToolCallRecord[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type KernelResult =
|
export type KernelResult =
|
||||||
@@ -39,6 +43,7 @@ export class InteractionKernel {
|
|||||||
private readonly seenIDs = new Set<string>();
|
private readonly seenIDs = new Set<string>();
|
||||||
private readonly seenOrder: string[] = [];
|
private readonly seenOrder: string[] = [];
|
||||||
private readonly presenceByActor = new Map<string, AgentPresence>();
|
private readonly presenceByActor = new Map<string, AgentPresence>();
|
||||||
|
private readonly toolCalls = new ToolCallStateMachine();
|
||||||
|
|
||||||
public constructor(private readonly maxRememberedEnvelopeIDs = 2_000) {
|
public constructor(private readonly maxRememberedEnvelopeIDs = 2_000) {
|
||||||
if (!Number.isSafeInteger(maxRememberedEnvelopeIDs) || maxRememberedEnvelopeIDs < 1) {
|
if (!Number.isSafeInteger(maxRememberedEnvelopeIDs) || maxRememberedEnvelopeIDs < 1) {
|
||||||
@@ -61,14 +66,24 @@ export class InteractionKernel {
|
|||||||
return {
|
return {
|
||||||
seen_envelope_ids: this.seenIDs.size,
|
seen_envelope_ids: this.seenIDs.size,
|
||||||
agent_presence: [...this.presenceByActor.values()].map(presence => ({ ...presence })),
|
agent_presence: [...this.presenceByActor.values()].map(presence => ({ ...presence })),
|
||||||
|
tool_calls: this.toolCalls.snapshot(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public submitToolCall(callID: string, updatedAt: string) {
|
||||||
|
return this.toolCalls.submit(callID, updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public completeToolCall(callID: string, status: ToolCallFinalStatus, result: JsonObject, updatedAt: string, error?: string) {
|
||||||
|
return this.toolCalls.complete({ call_id: callID, status, result, ...(error ? { error } : {}) }, updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear only the in-memory projection when the active conversation changes. */
|
/** Clear only the in-memory projection when the active conversation changes. */
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.seenIDs.clear();
|
this.seenIDs.clear();
|
||||||
this.seenOrder.length = 0;
|
this.seenOrder.length = 0;
|
||||||
this.presenceByActor.clear();
|
this.presenceByActor.clear();
|
||||||
|
this.toolCalls.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private remember(id: string): void {
|
private remember(id: string): void {
|
||||||
@@ -81,6 +96,18 @@ export class InteractionKernel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private project(item: ConversationItem, updatedAt: string): void {
|
private project(item: ConversationItem, updatedAt: string): void {
|
||||||
|
if (item.kind === "tool-call" && item.envelope) {
|
||||||
|
this.toolCalls.receive(item.call, item.envelope.conversation_id, updatedAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.kind === "tool-cancel") {
|
||||||
|
this.toolCalls.cancel(item.cancel, updatedAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.kind === "tool-result") {
|
||||||
|
this.toolCalls.complete(item.result, updatedAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (item.kind !== "agent-status" || !item.envelope) return;
|
if (item.kind !== "agent-status" || !item.envelope) return;
|
||||||
const actor = item.envelope.sender;
|
const actor = item.envelope.sender;
|
||||||
const actorKey = `${actor.kind}:${actor.id}`;
|
const actorKey = `${actor.kind}:${actor.id}`;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ToolCallRequest } from "../protocol";
|
||||||
|
import { ToolCallStateMachine } from "./tool-call-state";
|
||||||
|
|
||||||
|
const RECEIVED_AT = "2026-08-03T08:00:00.000Z";
|
||||||
|
|
||||||
|
function request(overrides: Partial<ToolCallRequest> = {}): ToolCallRequest {
|
||||||
|
return {
|
||||||
|
call_id: "call_state_001",
|
||||||
|
tool: "choice",
|
||||||
|
title: "Choose a path",
|
||||||
|
prompt: "Select one option.",
|
||||||
|
data: { source: "test" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ToolCallStateMachine", () => {
|
||||||
|
it("creates one pending record per call_id and preserves the first request", () => {
|
||||||
|
const state = new ToolCallStateMachine();
|
||||||
|
const first = state.receive(request(), "conversation_001", RECEIVED_AT);
|
||||||
|
const duplicate = state.receive(request({ title: "Replacement must be ignored" }), "conversation_002", "2026-08-03T08:01:00.000Z");
|
||||||
|
|
||||||
|
expect(first).toMatchObject({ disposition: "accepted", record: { status: "pending", conversation_id: "conversation_001" } });
|
||||||
|
expect(duplicate).toMatchObject({ disposition: "duplicate", record: { request: { title: "Choose a path" } } });
|
||||||
|
expect(state.snapshot()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permits only pending to submitted to a final result", () => {
|
||||||
|
const state = new ToolCallStateMachine();
|
||||||
|
state.receive(request(), "conversation_001", RECEIVED_AT);
|
||||||
|
|
||||||
|
expect(state.complete({ call_id: "call_state_001", status: "completed", result: { selected: "alpha" } }, "2026-08-03T08:01:00.000Z"))
|
||||||
|
.toMatchObject({ disposition: "invalid_transition", record: { status: "pending" } });
|
||||||
|
expect(state.submit("call_state_001", "2026-08-03T08:02:00.000Z"))
|
||||||
|
.toMatchObject({ disposition: "accepted", record: { status: "submitted" } });
|
||||||
|
expect(state.complete({ call_id: "call_state_001", status: "completed", result: { selected: "alpha" } }, "2026-08-03T08:03:00.000Z"))
|
||||||
|
.toMatchObject({ disposition: "accepted", record: { status: "completed", result: { selected: "alpha" } } });
|
||||||
|
expect(state.submit("call_state_001", "2026-08-03T08:04:00.000Z").disposition).toBe("invalid_transition");
|
||||||
|
expect(state.submit("missing_call", "2026-08-03T08:04:00.000Z").disposition).toBe("missing");
|
||||||
|
|
||||||
|
state.receive(request({ call_id: "call_state_failed" }), "conversation_001", RECEIVED_AT);
|
||||||
|
state.submit("call_state_failed", "2026-08-03T08:05:00.000Z");
|
||||||
|
expect(state.complete({ call_id: "call_state_failed", status: "failed", result: {}, error: "Validation rejected" }, "2026-08-03T08:06:00.000Z"))
|
||||||
|
.toMatchObject({ disposition: "accepted", record: { status: "failed", error: "Validation rejected" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels an active request but never changes a terminal record", () => {
|
||||||
|
const state = new ToolCallStateMachine();
|
||||||
|
state.receive(request(), "conversation_001", RECEIVED_AT);
|
||||||
|
|
||||||
|
expect(state.cancel({ call_id: "call_state_001", reason: "User left the flow" }, "2026-08-03T08:01:00.000Z"))
|
||||||
|
.toMatchObject({ disposition: "accepted", record: { status: "cancelled", error: "User left the flow" } });
|
||||||
|
expect(state.cancel({ call_id: "call_state_001", reason: "Again" }, "2026-08-03T08:02:00.000Z").disposition).toBe("invalid_transition");
|
||||||
|
expect(state.get("missing_call")?.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expires only active requests, including one that is already expired on receipt", () => {
|
||||||
|
const state = new ToolCallStateMachine();
|
||||||
|
state.receive(request({ call_id: "expired_on_receipt", expires_at: "2026-08-03T07:59:59.000Z" }), "conversation_001", RECEIVED_AT);
|
||||||
|
state.receive(request({ call_id: "expires_later", expires_at: "2026-08-03T08:05:00.000Z" }), "conversation_001", RECEIVED_AT);
|
||||||
|
state.receive(request({ call_id: "no_expiry" }), "conversation_001", RECEIVED_AT);
|
||||||
|
|
||||||
|
expect(state.get("expired_on_receipt")?.status).toBe("expired");
|
||||||
|
expect(state.expire("2026-08-03T08:05:00.000Z")).toEqual([
|
||||||
|
expect.objectContaining({ call_id: "expires_later", status: "expired" }),
|
||||||
|
]);
|
||||||
|
expect(state.get("no_expiry")?.status).toBe("pending");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type {
|
||||||
|
JsonObject,
|
||||||
|
ToolCallCancel,
|
||||||
|
ToolCallFinalStatus,
|
||||||
|
ToolCallRequest,
|
||||||
|
ToolCallResult,
|
||||||
|
} from "../protocol";
|
||||||
|
|
||||||
|
export type ToolCallStatus = "pending" | "submitted" | ToolCallFinalStatus;
|
||||||
|
|
||||||
|
export type ToolCallRecord = {
|
||||||
|
call_id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
request: ToolCallRequest;
|
||||||
|
status: ToolCallStatus;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
result?: JsonObject;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolCallTransition =
|
||||||
|
| { disposition: "accepted"; record: ToolCallRecord }
|
||||||
|
| { disposition: "duplicate" | "invalid_transition" | "missing"; record?: ToolCallRecord };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-neutral state machine for trusted standard interaction calls.
|
||||||
|
* It has no UI, persistence, Transport, capability, or side-effect handler;
|
||||||
|
* later M1 tasks connect those boundaries through this explicit lifecycle.
|
||||||
|
*/
|
||||||
|
export class ToolCallStateMachine {
|
||||||
|
private readonly calls = new Map<string, ToolCallRecord>();
|
||||||
|
|
||||||
|
public receive(request: ToolCallRequest, conversationID: string, receivedAt: string): ToolCallTransition {
|
||||||
|
const existing = this.calls.get(request.call_id);
|
||||||
|
if (existing) return { disposition: "duplicate", record: copy(existing) };
|
||||||
|
const expired = request.expires_at !== undefined && Date.parse(request.expires_at) <= Date.parse(receivedAt);
|
||||||
|
const record: ToolCallRecord = {
|
||||||
|
call_id: request.call_id,
|
||||||
|
conversation_id: conversationID,
|
||||||
|
request: { ...request, data: { ...request.data } },
|
||||||
|
status: expired ? "expired" : "pending",
|
||||||
|
created_at: receivedAt,
|
||||||
|
updated_at: receivedAt,
|
||||||
|
};
|
||||||
|
this.calls.set(record.call_id, record);
|
||||||
|
return { disposition: "accepted", record: copy(record) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public submit(callID: string, updatedAt: string): ToolCallTransition {
|
||||||
|
return this.transition(callID, updatedAt, ["pending"], record => { record.status = "submitted"; });
|
||||||
|
}
|
||||||
|
|
||||||
|
public complete(result: ToolCallResult, updatedAt: string): ToolCallTransition {
|
||||||
|
return this.transition(result.call_id, updatedAt, ["submitted"], record => {
|
||||||
|
record.status = result.status;
|
||||||
|
record.result = { ...result.result };
|
||||||
|
record.error = result.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancel(cancel: ToolCallCancel, updatedAt: string): ToolCallTransition {
|
||||||
|
return this.transition(cancel.call_id, updatedAt, ["pending", "submitted"], record => {
|
||||||
|
record.status = "cancelled";
|
||||||
|
record.error = cancel.reason || undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public expire(now: string): readonly ToolCallRecord[] {
|
||||||
|
const changed: ToolCallRecord[] = [];
|
||||||
|
const nowTime = Date.parse(now);
|
||||||
|
for (const record of this.calls.values()) {
|
||||||
|
if ((record.status === "pending" || record.status === "submitted") && record.request.expires_at && Date.parse(record.request.expires_at) <= nowTime) {
|
||||||
|
record.status = "expired";
|
||||||
|
record.updated_at = now;
|
||||||
|
changed.push(copy(record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(callID: string): ToolCallRecord | undefined {
|
||||||
|
const record = this.calls.get(callID);
|
||||||
|
return record ? copy(record) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public snapshot(): readonly ToolCallRecord[] {
|
||||||
|
return [...this.calls.values()].map(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.calls.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private transition(
|
||||||
|
callID: string,
|
||||||
|
updatedAt: string,
|
||||||
|
allowed: readonly ToolCallStatus[],
|
||||||
|
apply: (record: ToolCallRecord) => void,
|
||||||
|
): ToolCallTransition {
|
||||||
|
const record = this.calls.get(callID);
|
||||||
|
if (!record) return { disposition: "missing" };
|
||||||
|
if (!allowed.includes(record.status)) return { disposition: "invalid_transition", record: copy(record) };
|
||||||
|
apply(record);
|
||||||
|
record.updated_at = updatedAt;
|
||||||
|
return { disposition: "accepted", record: copy(record) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy(record: ToolCallRecord): ToolCallRecord {
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
request: { ...record.request, data: { ...record.request.data } },
|
||||||
|
...(record.result ? { result: { ...record.result } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -52,6 +52,18 @@ export class TrustedDOMRendererContext {
|
|||||||
this.renderSystemMessage(`Agent 错误:${item.message}`);
|
this.renderSystemMessage(`Agent 错误:${item.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public renderToolCall(item: Extract<ConversationItem, { kind: "tool-call" }>): void {
|
||||||
|
this.renderSystemMessage(`收到 ${item.call.tool} 标准交互请求;交互卡片将在 M1-02 / M1-03 接入。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderToolResult(item: Extract<ConversationItem, { kind: "tool-result" }>): void {
|
||||||
|
this.renderSystemMessage(`收到工具调用结果(${item.result.call_id} · ${item.result.status})。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderToolCancel(item: Extract<ConversationItem, { kind: "tool-cancel" }>): void {
|
||||||
|
this.renderSystemMessage(`工具调用已取消(${item.cancel.call_id})。`);
|
||||||
|
}
|
||||||
|
|
||||||
public renderSurface(item: Extract<ConversationItem, { kind: "surface" }>): void {
|
public renderSurface(item: Extract<ConversationItem, { kind: "surface" }>): void {
|
||||||
this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`);
|
this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user