feat: add trusted action groups

This commit is contained in:
2026-08-03 15:34:17 +08:00
parent c384368f66
commit bddf15a2d2
9 changed files with 173 additions and 11 deletions
+3 -1
View File
@@ -94,7 +94,9 @@ function loginFailureMessage(reason: unknown): string {
return message || "登录失败,请稍后重试。";
}
const rendererContext = new TrustedDOMRendererContext(messages, presence);
const rendererContext = new TrustedDOMRendererContext(messages, presence, (callID, submission) => {
return interactionKernel.submitToolCall(callID, new Date().toISOString(), submission).disposition === "accepted";
});
const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item));
rendererRegistry.register("markdown", (item, context) => context.renderMarkdown(item));
+38
View File
@@ -40,6 +40,19 @@ export type DeliveryState =
/** The only standard interaction requests admitted during Milestone 1. */
export type ToolCallKind = "choice" | "confirm" | "input";
export type ActionGroupMode = "single-choice" | "multi-choice" | "button";
export type ActionGroupAction = {
id: string;
label: string;
description?: string;
};
export type ActionGroup = {
mode: ActionGroupMode;
actions: readonly ActionGroupAction[];
};
export type ToolCallRequest = {
call_id: string;
tool: ToolCallKind;
@@ -47,6 +60,8 @@ export type ToolCallRequest = {
prompt: string;
expires_at?: string;
data: JsonObject;
/** Present only for the trusted `choice` renderer introduced in M1-02. */
action_group?: ActionGroup;
};
export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired";
@@ -267,11 +282,31 @@ function validAppCallPayload(payload: JsonObject): boolean {
const TOOL_KINDS = new Set<ToolCallKind>(["choice", "confirm", "input"]);
const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]);
const ACTION_GROUP_MODES = new Set<ActionGroupMode>(["single-choice", "multi-choice", "button"]);
function optionalTimestamp(value: unknown): string | undefined {
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined;
}
function parseActionGroup(value: unknown): ActionGroup | undefined {
const candidate = object(value);
const mode = text(candidate?.mode) as ActionGroupMode;
if (!candidate || !ACTION_GROUP_MODES.has(mode) || !Array.isArray(candidate.actions) || candidate.actions.length < 1 || candidate.actions.length > 12) return undefined;
const seen = new Set<string>();
const actions: ActionGroupAction[] = [];
for (const value of candidate.actions) {
const action = object(value);
const id = action && identifier(action.id);
const label = action && text(action.label).trim();
const description = action ? text(action.description).trim() : "";
if (!id || !label || label.length > 200 || seen.has(id)) return undefined;
if (description.length > 500) return undefined;
seen.add(id);
actions.push({ id, label, ...(description ? { description } : {}) });
}
return { mode, actions };
}
function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
const callID = identifier(payload.call_id);
const tool = text(payload.tool) as ToolCallKind;
@@ -280,6 +315,8 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
if (payload.expires_at !== undefined && !expiresAt) return undefined;
const data = payload.data === undefined ? {} : object(payload.data);
if (!data) return undefined;
const actionGroup = tool === "choice" ? parseActionGroup(data.action_group) : undefined;
if (tool === "choice" && !actionGroup) return undefined;
return {
call_id: callID,
tool,
@@ -287,6 +324,7 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
prompt: text(payload.prompt),
...(expiresAt ? { expires_at: expiresAt } : {}),
data,
...(actionGroup ? { action_group: actionGroup } : {}),
};
}
+34 -2
View File
@@ -78,7 +78,7 @@ describe("InteractionKernel", () => {
raw: envelope({
id: "evt_tool_call",
type: "lineup.v1.tool.call",
payload: { call_id: "call_kernel_001", tool: "choice", title: "Choose", prompt: "One", data: {} },
payload: { call_id: "call_kernel_001", tool: "choice", title: "Choose", prompt: "One", data: { action_group: { mode: "single-choice", actions: [{ id: "alpha", label: "Alpha" }] } } },
}),
source: "sync",
received_at: "2026-08-03T08:00:00.000Z",
@@ -87,7 +87,7 @@ describe("InteractionKernel", () => {
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: {} },
payload: { call_id: "call_kernel_001", tool: "choice", title: "Ignored", prompt: "Ignored", data: { action_group: { mode: "button", actions: [{ id: "beta", label: "Beta" }] } } },
}),
source: "live",
});
@@ -126,4 +126,36 @@ describe("InteractionKernel", () => {
expect(invalidCall.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
expect(invalidResult.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
});
it("accepts only a bounded, uniquely identified ActionGroup for choice calls", () => {
const kernel = new InteractionKernel();
const accepted = kernel.ingest({
raw: envelope({
id: "evt_choice_group",
type: "lineup.v1.tool.call",
payload: {
call_id: "call_choice_group",
tool: "choice",
title: "Pick deployment targets",
prompt: "You may select multiple targets.",
data: { action_group: { mode: "multi-choice", actions: [{ id: "staging", label: "Staging" }, { id: "production", label: "Production", description: "Requires approval" }] } },
},
}),
source: "sync",
});
const duplicateActionID = kernel.ingest({
raw: envelope({
id: "evt_choice_invalid_group",
type: "lineup.v1.tool.call",
payload: { call_id: "call_bad_group", tool: "choice", title: "Bad", prompt: "Bad", data: { action_group: { mode: "multi-choice", actions: [{ id: "same", label: "One" }, { id: "same", label: "Two" }] } } },
}),
source: "sync",
});
expect(accepted.items).toEqual([expect.objectContaining({
kind: "tool-call",
call: expect.objectContaining({ action_group: { mode: "multi-choice", actions: [{ id: "staging", label: "Staging" }, { id: "production", label: "Production", description: "Requires approval" }] } }),
})]);
expect(duplicateActionID.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
});
});
+2 -2
View File
@@ -70,8 +70,8 @@ export class InteractionKernel {
};
}
public submitToolCall(callID: string, updatedAt: string) {
return this.toolCalls.submit(callID, updatedAt);
public submitToolCall(callID: string, updatedAt: string, submission: JsonObject = {}) {
return this.toolCalls.submit(callID, updatedAt, submission);
}
public completeToolCall(callID: string, status: ToolCallFinalStatus, result: JsonObject, updatedAt: string, error?: string) {
+2 -2
View File
@@ -32,8 +32,8 @@ describe("ToolCallStateMachine", () => {
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.submit("call_state_001", "2026-08-03T08:02:00.000Z", { action_ids: ["alpha"] }))
.toMatchObject({ disposition: "accepted", record: { status: "submitted", submission: { action_ids: ["alpha"] } } });
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");
+7 -2
View File
@@ -16,6 +16,7 @@ export type ToolCallRecord = {
created_at: string;
updated_at: string;
result?: JsonObject;
submission?: JsonObject;
error?: string;
};
@@ -47,8 +48,11 @@ export class ToolCallStateMachine {
return { disposition: "accepted", record: copy(record) };
}
public submit(callID: string, updatedAt: string): ToolCallTransition {
return this.transition(callID, updatedAt, ["pending"], record => { record.status = "submitted"; });
public submit(callID: string, updatedAt: string, submission: JsonObject = {}): ToolCallTransition {
return this.transition(callID, updatedAt, ["pending"], record => {
record.status = "submitted";
record.submission = { ...submission };
});
}
public complete(result: ToolCallResult, updatedAt: string): ToolCallTransition {
@@ -112,5 +116,6 @@ function copy(record: ToolCallRecord): ToolCallRecord {
...record,
request: { ...record.request, data: { ...record.request.data } },
...(record.result ? { result: { ...record.result } } : {}),
...(record.submission ? { submission: { ...record.submission } } : {}),
};
}
+80 -1
View File
@@ -1,10 +1,14 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import type {
ActionGroup,
ConversationItem,
DeliveryState,
JsonObject,
} from "../protocol";
export type ToolActionSubmitter = (callID: string, submission: JsonObject) => boolean;
/**
* Trusted presentation context for the first-party chat shell. It owns only
* DOM nodes and display-local state; it deliberately has no Store, Transport,
@@ -17,6 +21,7 @@ export class TrustedDOMRendererContext {
public constructor(
private readonly messages: HTMLElement,
private readonly presence: HTMLElement,
private readonly submitToolAction?: ToolActionSubmitter,
) {}
public reset(): void {
@@ -53,7 +58,11 @@ export class TrustedDOMRendererContext {
}
public renderToolCall(item: Extract<ConversationItem, { kind: "tool-call" }>): void {
this.renderSystemMessage(`收到 ${item.call.tool} 标准交互请求;交互卡片将在 M1-02 / M1-03 接入。`);
if (item.call.tool === "choice" && item.call.action_group) {
this.renderActionGroup(item.call.call_id, item.call.title, item.call.prompt, item.call.action_group, item.call.expires_at);
return;
}
this.renderSystemMessage(`收到 ${item.call.tool} 标准交互请求;交互卡片将在 M1-03 接入。`);
}
public renderToolResult(item: Extract<ConversationItem, { kind: "tool-result" }>): void {
@@ -131,6 +140,76 @@ export class TrustedDOMRendererContext {
return row;
}
private renderActionGroup(callID: string, title: string, prompt: string, group: ActionGroup, expiresAt?: string): void {
const card = document.createElement("article");
card.className = "action-group-card";
card.dataset.callId = callID;
const heading = document.createElement("strong");
heading.textContent = title || "请选择";
const detail = document.createElement("p");
detail.textContent = prompt;
const actions = document.createElement("div");
actions.className = `action-group-actions ${group.mode}`;
const status = document.createElement("small");
status.className = "action-group-status";
const expired = expiresAt !== undefined && Date.parse(expiresAt) <= Date.now();
status.textContent = expired ? "该交互已过期" : "等待你的选择";
const buttons: HTMLButtonElement[] = [];
const selected = new Set<string>();
const submit = (actionIDs: readonly string[]) => {
const accepted = this.submitToolAction?.(callID, { action_ids: [...actionIDs] }) ?? false;
if (!accepted) {
status.textContent = "该交互已结束或已提交";
return;
}
card.dataset.status = "submitted";
status.textContent = "已提交,等待 Agent 确认";
for (const button of buttons) button.disabled = true;
};
for (const action of group.actions) {
const button = document.createElement("button");
button.type = "button";
button.className = "action-group-action";
button.textContent = action.label;
button.title = action.description ?? action.label;
button.disabled = expired;
button.addEventListener("click", () => {
if (group.mode !== "multi-choice") {
submit([action.id]);
return;
}
if (selected.has(action.id)) selected.delete(action.id);
else selected.add(action.id);
button.setAttribute("aria-pressed", String(selected.has(action.id)));
submitButton.disabled = selected.size === 0;
});
buttons.push(button);
actions.append(button);
}
let submitButton: HTMLButtonElement;
if (group.mode === "multi-choice") {
submitButton = document.createElement("button");
submitButton.type = "button";
submitButton.className = "action-group-submit";
submitButton.textContent = "提交选择";
submitButton.disabled = true;
submitButton.addEventListener("click", () => submit([...selected]));
buttons.push(submitButton);
} else {
// It is only referenced by the multi-choice click handler above.
submitButton = document.createElement("button");
}
card.append(heading, detail, actions);
if (group.mode === "multi-choice") card.append(submitButton);
card.append(status);
if (expired) card.dataset.status = "expired";
this.messages.append(card);
this.scrollLatest();
}
private showThinking(detail: string): void {
this.thinking?.remove();
this.thinking = this.appendBubble("agent", detail);
+1 -1
View File
@@ -1 +1 @@
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e9f0ec;background:#101815;font-synthesis:none}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;min-width:320px;min-height:100vh}.shell{min-height:100vh;background:radial-gradient(circle at 5% 0,#29473d 0,transparent 32rem),#101815}.login-view{display:grid;place-items:center;gap:2.5rem;min-height:100vh;padding:2rem}.brand{display:flex;align-items:center;gap:1rem}.brand>span,.avatar{display:grid;place-items:center;border-radius:50%;background:#b8e4cf;color:#11251d;font-weight:800}.brand>span{width:3.2rem;height:3.2rem;font-size:1.4rem}.brand p{margin:0;color:#a5c9b8;font-size:.75rem;letter-spacing:.16em}.brand h1{margin:.25rem 0 0;font-size:1.5rem}.login-card{display:grid;width:min(100%,26rem);gap:1rem;padding:1.5rem;border:1px solid #365245;border-radius:1rem;background:#18251f;box-shadow:0 1rem 4rem #0006}.login-card label{display:grid;gap:.45rem;color:#b8c9c0;font-size:.85rem}.login-card input,.composer textarea{border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.75rem;font:inherit}.login-card button,.composer button{border:0;border-radius:.65rem;padding:.8rem 1rem;background:#a9dfc4;color:#102018;font-weight:750;cursor:pointer}.error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.85rem}.chat-view{display:grid;grid-template-rows:auto 1fr auto;height:100vh;max-width:62rem;margin:auto;border-inline:1px solid #2d4438;background:#142019}.chat-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #2c4437}.agent{display:flex;align-items:center;gap:.75rem}.avatar{width:2.4rem;height:2.4rem}.agent strong,.agent small{display:block}.agent small{margin-top:.12rem;color:#9bb5a7;font-size:.8rem}.quiet{border:0;background:transparent;color:#b7cabe;cursor:pointer}.messages{overflow:auto;padding:1.25rem;display:grid;align-content:start;gap:.75rem}.message-row{display:flex;gap:.6rem;max-width:min(85%,45rem)}.message-row.human{justify-self:end}.message-row.human .bubble{background:#a9dfc4;color:#122119}.message-avatar{display:grid;place-items:center;width:1.75rem;height:1.75rem;border-radius:50%;background:#315d4a;color:#d8f5e5;font-size:.8rem}.bubble{padding:.75rem .9rem;border-radius:.9rem;background:#23342b;color:#e7f0eb;line-height:1.55;overflow-wrap:anywhere}.meta{display:block;margin-top:.45rem;opacity:.6;font-size:.68rem}.markdown :first-child{margin-top:0}.markdown :last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:.7rem;border-radius:.5rem;background:#0b110e}.markdown code{font-family:"SFMono-Regular",Consolas,monospace}.system{justify-self:center;margin:.35rem 0;color:#98afa2;font-size:.78rem}.thinking .bubble{color:#c7e3d5}.composer{display:flex;gap:.75rem;padding:1rem;border-top:1px solid #2c4437;background:#16241d}.composer textarea{flex:1;max-height:8rem;resize:vertical}.composer button:disabled{opacity:.5}@media(max-width:560px){.chat-view{border:0}.message-row{max-width:94%}.login-view{gap:1.4rem;padding:1rem}}
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e9f0ec;background:#101815;font-synthesis:none}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;min-width:320px;min-height:100vh}.shell{min-height:100vh;background:radial-gradient(circle at 5% 0,#29473d 0,transparent 32rem),#101815}.login-view{display:grid;place-items:center;gap:2.5rem;min-height:100vh;padding:2rem}.brand{display:flex;align-items:center;gap:1rem}.brand>span,.avatar{display:grid;place-items:center;border-radius:50%;background:#b8e4cf;color:#11251d;font-weight:800}.brand>span{width:3.2rem;height:3.2rem;font-size:1.4rem}.brand p{margin:0;color:#a5c9b8;font-size:.75rem;letter-spacing:.16em}.brand h1{margin:.25rem 0 0;font-size:1.5rem}.login-card{display:grid;width:min(100%,26rem);gap:1rem;padding:1.5rem;border:1px solid #365245;border-radius:1rem;background:#18251f;box-shadow:0 1rem 4rem #0006}.login-card label{display:grid;gap:.45rem;color:#b8c9c0;font-size:.85rem}.login-card input,.composer textarea{border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.75rem;font:inherit}.login-card button,.composer button,.action-group-card button{border:0;border-radius:.65rem;padding:.8rem 1rem;background:#a9dfc4;color:#102018;font-weight:750;cursor:pointer}.error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.85rem}.chat-view{display:grid;grid-template-rows:auto 1fr auto;height:100vh;max-width:62rem;margin:auto;border-inline:1px solid #2d4438;background:#142019}.chat-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #2c4437}.agent{display:flex;align-items:center;gap:.75rem}.avatar{width:2.4rem;height:2.4rem}.agent strong,.agent small{display:block}.agent small{margin-top:.12rem;color:#9bb5a7;font-size:.8rem}.quiet{border:0;background:transparent;color:#b7cabe;cursor:pointer}.messages{overflow:auto;padding:1.25rem;display:grid;align-content:start;gap:.75rem}.message-row{display:flex;gap:.6rem;max-width:min(85%,45rem)}.message-row.human{justify-self:end}.message-row.human .bubble{background:#a9dfc4;color:#122119}.message-avatar{display:grid;place-items:center;width:1.75rem;height:1.75rem;border-radius:50%;background:#315d4a;color:#d8f5e5;font-size:.8rem}.bubble{padding:.75rem .9rem;border-radius:.9rem;background:#23342b;color:#e7f0eb;line-height:1.55;overflow-wrap:anywhere}.meta{display:block;margin-top:.45rem;opacity:.6;font-size:.68rem}.markdown :first-child{margin-top:0}.markdown :last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:.7rem;border-radius:.5rem;background:#0b110e}.markdown code{font-family:"SFMono-Regular",Consolas,monospace}.system{justify-self:center;margin:.35rem 0;color:#98afa2;font-size:.78rem}.thinking .bubble{color:#c7e3d5}.action-group-card{display:grid;justify-self:start;gap:.7rem;width:min(100%,32rem);padding:1rem;border:1px solid #537362;border-radius:.9rem;background:#1c2c24}.action-group-card>p{margin:0;color:#bfd1c6;line-height:1.45}.action-group-actions{display:grid;gap:.5rem}.action-group-actions.multi-choice{grid-template-columns:repeat(auto-fit,minmax(8rem,1fr))}.action-group-actions.button{grid-template-columns:repeat(auto-fit,minmax(7rem,1fr))}.action-group-action{min-height:2.7rem;text-align:left}.action-group-actions.button .action-group-action{text-align:center}.action-group-action[aria-pressed="true"]{outline:2px solid #e0f8ea;background:#d0f0db}.action-group-submit{justify-self:start}.action-group-status{color:#aac5b5}.action-group-card[data-status="submitted"]{border-color:#89c7a6}.composer{display:flex;gap:.75rem;padding:1rem;border-top:1px solid #2c4437;background:#16241d}.composer textarea{flex:1;max-height:8rem;resize:vertical}.composer button:disabled,.action-group-card button:disabled{opacity:.5;cursor:not-allowed}@media(max-width:560px){.chat-view{border:0}.message-row{max-width:94%}.login-view{gap:1.4rem;padding:1rem}}