diff --git a/tauri/README.md b/tauri/README.md index 9732c0f..692d39e 100644 --- a/tauri/README.md +++ b/tauri/README.md @@ -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` 均通过。 +### 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 的改造对象) `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 功能。 diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 68d8114..5c62393 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -101,6 +101,9 @@ rendererRegistry.register("markdown", (item, context) => context.renderMarkdown( rendererRegistry.register("agent-status", (item, context) => context.renderAgentStatus(item)); rendererRegistry.register("progress", (item, context) => context.renderProgress(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("app-call", (item, context) => context.renderAppCall(item)); rendererRegistry.register("fallback", (item, context) => context.renderFallback(item)); diff --git a/tauri/src/protocol.ts b/tauri/src/protocol.ts index 1be8578..adee268 100644 --- a/tauri/src/protocol.ts +++ b/tauri/src/protocol.ts @@ -37,6 +37,32 @@ export type DeliveryState = | { status: "delivered"; updated_at: string; transport_id?: string } | { 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. * 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: "progress"; title: string; percent: number; status: 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: "app-call"; 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.progress", "lineup.v1.error", + "lineup.v1.tool.call", + "lineup.v1.tool.result", + "lineup.v1.tool.cancel", "lineup.v1.ui.open", "lineup.v1.ui.patch", "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)); } +const TOOL_KINDS = new Set(["choice", "confirm", "input"]); +const TOOL_FINAL_STATUSES = new Set(["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 { return { kind: "fallback", @@ -289,6 +359,18 @@ export function decodeConversationItem(raw: string): ConversationItem { ? { kind: "error", message, ...(text(payload.code) ? { code: text(payload.code) } : {}), ...base } : 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.patch": case "lineup.v1.ui.close": diff --git a/tauri/src/runtime/interaction-kernel.test.ts b/tauri/src/runtime/interaction-kernel.test.ts index 3871857..c9b4260 100644 --- a/tauri/src/runtime/interaction-kernel.test.ts +++ b/tauri/src/runtime/interaction-kernel.test.ts @@ -71,4 +71,59 @@ describe("InteractionKernel", () => { // 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"); }); + + 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" })]); + }); }); diff --git a/tauri/src/runtime/interaction-kernel.ts b/tauri/src/runtime/interaction-kernel.ts index 0c1b5e2..2cae883 100644 --- a/tauri/src/runtime/interaction-kernel.ts +++ b/tauri/src/runtime/interaction-kernel.ts @@ -2,7 +2,10 @@ import { decodeConversationItem, type Actor, type ConversationItem, + type JsonObject, + type ToolCallFinalStatus, } from "../protocol"; +import { ToolCallStateMachine, type ToolCallRecord } from "./tool-call-state"; /** Transport metadata is intentionally opaque to the protocol and renderer. */ export type IncomingMessage = { @@ -23,6 +26,7 @@ export type AgentPresence = { export type KernelSnapshot = { seen_envelope_ids: number; agent_presence: readonly AgentPresence[]; + tool_calls: readonly ToolCallRecord[]; }; export type KernelResult = @@ -39,6 +43,7 @@ export class InteractionKernel { private readonly seenIDs = new Set(); private readonly seenOrder: string[] = []; private readonly presenceByActor = new Map(); + private readonly toolCalls = new ToolCallStateMachine(); public constructor(private readonly maxRememberedEnvelopeIDs = 2_000) { if (!Number.isSafeInteger(maxRememberedEnvelopeIDs) || maxRememberedEnvelopeIDs < 1) { @@ -61,14 +66,24 @@ export class InteractionKernel { return { seen_envelope_ids: this.seenIDs.size, 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. */ public reset(): void { this.seenIDs.clear(); this.seenOrder.length = 0; this.presenceByActor.clear(); + this.toolCalls.reset(); } private remember(id: string): void { @@ -81,6 +96,18 @@ export class InteractionKernel { } 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; const actor = item.envelope.sender; const actorKey = `${actor.kind}:${actor.id}`; diff --git a/tauri/src/runtime/tool-call-state.test.ts b/tauri/src/runtime/tool-call-state.test.ts new file mode 100644 index 0000000..c992869 --- /dev/null +++ b/tauri/src/runtime/tool-call-state.test.ts @@ -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 { + 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"); + }); +}); diff --git a/tauri/src/runtime/tool-call-state.ts b/tauri/src/runtime/tool-call-state.ts new file mode 100644 index 0000000..b7ff1ff --- /dev/null +++ b/tauri/src/runtime/tool-call-state.ts @@ -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(); + + 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 } } : {}), + }; +} diff --git a/tauri/src/runtime/trusted-dom-renderers.ts b/tauri/src/runtime/trusted-dom-renderers.ts index 13f072a..0e74900 100644 --- a/tauri/src/runtime/trusted-dom-renderers.ts +++ b/tauri/src/runtime/trusted-dom-renderers.ts @@ -52,6 +52,18 @@ export class TrustedDOMRendererContext { this.renderSystemMessage(`Agent 错误:${item.message}`); } + public renderToolCall(item: Extract): void { + this.renderSystemMessage(`收到 ${item.call.tool} 标准交互请求;交互卡片将在 M1-02 / M1-03 接入。`); + } + + public renderToolResult(item: Extract): void { + this.renderSystemMessage(`收到工具调用结果(${item.result.call_id} · ${item.result.status})。`); + } + + public renderToolCancel(item: Extract): void { + this.renderSystemMessage(`工具调用已取消(${item.cancel.call_id})。`); + } + public renderSurface(item: Extract): void { this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`); }