feat: aggregate task progress updates
This commit is contained in:
@@ -68,6 +68,12 @@ M0-01~M0-08 已完成。Mac 经 Linux Tailscale Web Host `http://100.121.118.1
|
||||
|
||||
确认、取消、表单本地校验、提交与超时都更新同一 `call_id` 的状态,并在终态禁用控件。Conversation Store v4 额外持久化 Tool Call projection 及未提交表单草稿:刷新或重新登录时,待处理卡片与草稿会恢复;离线期间到期的 call 会恢复为不可操作的 expired。提交仍不发送网络请求或 `tool.result`,也不处理 Agent echo 或 Hermes Adapter。`npm test` 当前为 3 个文件 / 18 个测试通过,`npm run build` 通过。
|
||||
|
||||
### M1-04 任务与进度聚合(已完成)
|
||||
|
||||
已有的不带 `operation_id` 的 `agent.progress` 仍显示为兼容性系统提示;带 `operation_id` 的更新必须使用严格的 task schema,并聚合到同一张可信原生任务卡片。每次进度、状态或 Agent `cancelled` 更新只更新卡片,不会无限新增聊天消息。可取消任务提供“请求取消”入口;本阶段它只记录本地请求并禁用入口,等待后续 Agent 状态确认。
|
||||
|
||||
Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显示任务最新状态。尚未创建取消 outbox、尚未发送任何网络事件、尚未实现 result echo 或 Hermes Adapter 契约。`npm test` 当前为 4 个文件 / 22 个测试通过,`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 功能。
|
||||
|
||||
@@ -116,6 +116,13 @@ const rendererContext = new TrustedDOMRendererContext(messages, presence, {
|
||||
loadDraft(callID) { return conversationStore.getToolDraft(callID); },
|
||||
saveDraft(callID, values) { conversationStore.saveToolDraft(callID, values); },
|
||||
clearDraft(callID) { conversationStore.clearToolDraft(callID); },
|
||||
}, {
|
||||
requestCancel(operationID) {
|
||||
const transition = interactionKernel.requestTaskCancel(operationID, new Date().toISOString());
|
||||
conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
|
||||
return transition.disposition === "accepted";
|
||||
},
|
||||
read(operationID) { return interactionKernel.snapshot().tasks.find(task => task.operation_id === operationID); },
|
||||
});
|
||||
const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
|
||||
rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item));
|
||||
@@ -186,6 +193,9 @@ async function syncLoop(): Promise<void> {
|
||||
if (result.items.some(item => item.kind === "tool-call" || item.kind === "tool-result" || item.kind === "tool-cancel")) {
|
||||
conversationStore.replaceToolCalls(result.snapshot.tool_calls);
|
||||
}
|
||||
if (result.items.some(item => item.kind === "progress" && item.task)) {
|
||||
conversationStore.replaceTasks(result.snapshot.tasks);
|
||||
}
|
||||
for (const item of result.items) {
|
||||
if (conversationStore.recordIncoming(item, message.message_seq, new Date().toISOString())) renderIncoming(item);
|
||||
}
|
||||
@@ -233,7 +243,9 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
|
||||
session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true };
|
||||
const snapshot = conversationStore.open(currentConversationKey());
|
||||
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
|
||||
interactionKernel.restoreTasks(snapshot.tasks);
|
||||
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
|
||||
conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
|
||||
session.lastSeq = snapshot.cursor;
|
||||
messages.replaceChildren(); rendererContext.reset();
|
||||
loginView.hidden = true; chatView.hidden = false;
|
||||
|
||||
+24
-2
@@ -106,6 +106,16 @@ export type ToolCallCancel = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type TaskStatus = "running" | "waiting" | "completed" | "failed" | "cancelled";
|
||||
|
||||
export type TaskProgress = {
|
||||
operation_id: string;
|
||||
title: string;
|
||||
percent: number;
|
||||
status: TaskStatus;
|
||||
cancellable: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A user-originated action awaiting delivery through the Transport Adapter.
|
||||
* It is data only: the Store owns retries and the Kernel owns state changes.
|
||||
@@ -172,7 +182,7 @@ export type ConversationItem =
|
||||
| (ConversationItemBase & { kind: "user-message"; text: string; delivery: DeliveryState })
|
||||
| (ConversationItemBase & { kind: "markdown"; markdown: 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; task?: TaskProgress })
|
||||
| (ConversationItemBase & { kind: "error"; message: string; code?: string })
|
||||
| (ConversationItemBase & { kind: "tool-call"; call: ToolCallRequest })
|
||||
| (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult })
|
||||
@@ -312,6 +322,7 @@ 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"]);
|
||||
const INPUT_FIELD_TYPES = new Set<InputFieldType>(["text", "textarea", "number"]);
|
||||
const TASK_STATUSES = new Set<TaskStatus>(["running", "waiting", "completed", "failed", "cancelled"]);
|
||||
|
||||
function optionalTimestamp(value: unknown): string | undefined {
|
||||
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined;
|
||||
@@ -416,6 +427,15 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
|
||||
};
|
||||
}
|
||||
|
||||
function parseTaskProgress(payload: JsonObject, percent: number): TaskProgress | undefined {
|
||||
const operationID = identifier(payload.operation_id);
|
||||
const status = text(payload.status) as TaskStatus;
|
||||
if (!operationID || !TASK_STATUSES.has(status)) return undefined;
|
||||
const title = text(payload.title).trim();
|
||||
if (!title || title.length > 200) return undefined;
|
||||
return { operation_id: operationID, title, percent, status, cancellable: payload.cancellable === true };
|
||||
}
|
||||
|
||||
function parseToolResult(payload: JsonObject): ToolCallResult | undefined {
|
||||
const callID = identifier(payload.call_id);
|
||||
const status = text(payload.status) as ToolCallFinalStatus;
|
||||
@@ -477,7 +497,9 @@ export function decodeConversationItem(raw: string): ConversationItem {
|
||||
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) {
|
||||
return fallbackItem({ code: "invalid_payload", detail: "Agent progress payload requires percent between 0 and 100.", envelope });
|
||||
}
|
||||
return { kind: "progress", title: text(payload.title), percent, status: text(payload.status), ...base };
|
||||
const task = payload.operation_id === undefined ? undefined : parseTaskProgress(payload, percent);
|
||||
if (payload.operation_id !== undefined && !task) return fallbackItem({ code: "invalid_payload", detail: "Task progress requires operation_id, title, supported status, and valid cancellation flag.", envelope });
|
||||
return { kind: "progress", title: text(payload.title), percent, status: text(payload.status), ...(task ? { task } : {}), ...base };
|
||||
}
|
||||
case "lineup.v1.error": {
|
||||
const message = text(payload.message);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolCallRecord } from "./tool-call-state";
|
||||
import type { TaskRecord } from "./task-state";
|
||||
import { ConversationStore } from "./conversation-store";
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
@@ -97,4 +98,16 @@ describe("ConversationStore", () => {
|
||||
expect(reopened.tool_calls).toEqual([expect.objectContaining({ call_id: "call_form_001", status: "pending" })]);
|
||||
expect(reopened.tool_drafts).toEqual({ call_form_001: { version: "1.2.3" } });
|
||||
});
|
||||
|
||||
it("persists the latest aggregated task record per conversation", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const task: TaskRecord = {
|
||||
operation_id: "task_store_001", conversation_id: conversationID, title: "Render preview", percent: 70, status: "running", cancellable: true, updated_at: createdAt,
|
||||
};
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.replaceTasks([task]);
|
||||
|
||||
expect(new ConversationStore(storage).open(conversationID).tasks).toEqual([expect.objectContaining({ operation_id: "task_store_001", percent: 70 })]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ConversationItem, DeliveryState, JsonObject } from "../protocol";
|
||||
import type { ToolCallRecord } from "./tool-call-state";
|
||||
import type { TaskRecord } from "./task-state";
|
||||
|
||||
export type StoredConversationItem = {
|
||||
local_id: string;
|
||||
@@ -21,15 +22,17 @@ export type ConversationSnapshot = {
|
||||
outbox: readonly OutboxEntry[];
|
||||
tool_calls: readonly ToolCallRecord[];
|
||||
tool_drafts: Readonly<Record<string, JsonObject>>;
|
||||
tasks: readonly TaskRecord[];
|
||||
};
|
||||
|
||||
type PersistedConversation = {
|
||||
version: 4;
|
||||
version: 5;
|
||||
cursor: number;
|
||||
items: StoredConversationItem[];
|
||||
outbox: OutboxEntry[];
|
||||
tool_calls: ToolCallRecord[];
|
||||
tool_drafts: Record<string, JsonObject>;
|
||||
tasks: TaskRecord[];
|
||||
};
|
||||
|
||||
const STORE_PREFIX = "lineup.conversation.v1";
|
||||
@@ -44,7 +47,7 @@ const STORE_PREFIX = "lineup.conversation.v1";
|
||||
*/
|
||||
export class ConversationStore {
|
||||
private activeConversationID: string | undefined;
|
||||
private state: PersistedConversation = { version: 4, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {} };
|
||||
private state: PersistedConversation = emptyConversation();
|
||||
|
||||
public constructor(private readonly storage: Storage) {}
|
||||
|
||||
@@ -56,7 +59,7 @@ export class ConversationStore {
|
||||
|
||||
public close(): void {
|
||||
this.activeConversationID = undefined;
|
||||
this.state = { version: 4, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {} };
|
||||
this.state = emptyConversation();
|
||||
}
|
||||
|
||||
public snapshot(): ConversationSnapshot {
|
||||
@@ -67,6 +70,7 @@ export class ConversationStore {
|
||||
outbox: this.state.outbox.map(entry => ({ ...entry })),
|
||||
tool_calls: clone(this.state.tool_calls),
|
||||
tool_drafts: clone(this.state.tool_drafts),
|
||||
tasks: clone(this.state.tasks),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,6 +96,11 @@ export class ConversationStore {
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public replaceTasks(records: readonly TaskRecord[]): void {
|
||||
this.state.tasks = records.map(record => clone(record));
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
|
||||
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
|
||||
const stored: StoredConversationItem = {
|
||||
@@ -188,26 +197,35 @@ export class ConversationStore {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null");
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation();
|
||||
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown };
|
||||
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
|
||||
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown };
|
||||
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
|
||||
return emptyConversation();
|
||||
}
|
||||
|
||||
if (candidate.version !== 4) {
|
||||
if (candidate.version !== 5) {
|
||||
// v1 advanced its cursor from a send acknowledgement. Some browsers
|
||||
// had already been upgraded to v2 before that correction, retaining a
|
||||
// high cursor with holes for their own echoes. Preserve every valid
|
||||
// local item but replay the server history once to repair those holes;
|
||||
// the next persist upgrades this record to v4.
|
||||
return { version: 4, cursor: 0, items: candidate.items as StoredConversationItem[], outbox: candidate.outbox as OutboxEntry[], tool_calls: [], tool_drafts: {} };
|
||||
// the next persist upgrades this record to v5.
|
||||
return {
|
||||
version: 5,
|
||||
cursor: candidate.version === 4 ? Math.max(0, candidate.cursor) : 0,
|
||||
items: candidate.items as StoredConversationItem[],
|
||||
outbox: candidate.outbox as OutboxEntry[],
|
||||
tool_calls: candidate.version === 4 && Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
|
||||
tool_drafts: candidate.version === 4 && isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
|
||||
tasks: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 4,
|
||||
version: 5,
|
||||
cursor: Math.max(0, candidate.cursor),
|
||||
items: candidate.items as StoredConversationItem[],
|
||||
outbox: candidate.outbox as OutboxEntry[],
|
||||
tool_calls: Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
|
||||
tool_drafts: isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
|
||||
tasks: Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [],
|
||||
};
|
||||
} catch {
|
||||
return emptyConversation();
|
||||
@@ -229,7 +247,7 @@ export class ConversationStore {
|
||||
}
|
||||
|
||||
function emptyConversation(): PersistedConversation {
|
||||
return { version: 4, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {} };
|
||||
return { version: 5, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [] };
|
||||
}
|
||||
|
||||
function isDraftMap(value: unknown): value is Record<string, JsonObject> {
|
||||
|
||||
@@ -178,4 +178,21 @@ describe("InteractionKernel", () => {
|
||||
expect(form.items).toEqual([expect.objectContaining({ kind: "tool-call", call: expect.objectContaining({ form: expect.objectContaining({ fields: expect.arrayContaining([expect.objectContaining({ id: "version", required: true })]) }) }) })]);
|
||||
expect(invalid.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
|
||||
});
|
||||
|
||||
it("aggregates structured task progress by operation_id and exposes a local cancellation request", () => {
|
||||
const kernel = new InteractionKernel();
|
||||
const first = kernel.ingest({
|
||||
raw: envelope({ id: "evt_task_first", type: "lineup.v1.agent.progress", payload: { operation_id: "task_kernel_001", title: "Build report", percent: 15, status: "running", cancellable: true } }),
|
||||
source: "sync",
|
||||
});
|
||||
const update = kernel.ingest({
|
||||
raw: envelope({ id: "evt_task_update", type: "lineup.v1.agent.progress", payload: { operation_id: "task_kernel_001", title: "Build report", percent: 65, status: "running", cancellable: true } }),
|
||||
source: "live",
|
||||
});
|
||||
|
||||
expect(first.snapshot.tasks).toEqual([expect.objectContaining({ operation_id: "task_kernel_001", percent: 15 })]);
|
||||
expect(update.snapshot.tasks).toEqual([expect.objectContaining({ operation_id: "task_kernel_001", percent: 65 })]);
|
||||
expect(kernel.requestTaskCancel("task_kernel_001", "2026-08-03T09:05:00.000Z")).toMatchObject({ disposition: "accepted" });
|
||||
expect(kernel.snapshot().tasks).toEqual([expect.objectContaining({ cancel_requested_at: "2026-08-03T09:05:00.000Z" })]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ToolCallFinalStatus,
|
||||
} from "../protocol";
|
||||
import { ToolCallStateMachine, type ToolCallRecord } from "./tool-call-state";
|
||||
import { TaskStateMachine, type TaskRecord } from "./task-state";
|
||||
|
||||
/** Transport metadata is intentionally opaque to the protocol and renderer. */
|
||||
export type IncomingMessage = {
|
||||
@@ -27,6 +28,7 @@ export type KernelSnapshot = {
|
||||
seen_envelope_ids: number;
|
||||
agent_presence: readonly AgentPresence[];
|
||||
tool_calls: readonly ToolCallRecord[];
|
||||
tasks: readonly TaskRecord[];
|
||||
};
|
||||
|
||||
export type KernelResult =
|
||||
@@ -44,6 +46,7 @@ export class InteractionKernel {
|
||||
private readonly seenOrder: string[] = [];
|
||||
private readonly presenceByActor = new Map<string, AgentPresence>();
|
||||
private readonly toolCalls = new ToolCallStateMachine();
|
||||
private readonly tasks = new TaskStateMachine();
|
||||
|
||||
public constructor(private readonly maxRememberedEnvelopeIDs = 2_000) {
|
||||
if (!Number.isSafeInteger(maxRememberedEnvelopeIDs) || maxRememberedEnvelopeIDs < 1) {
|
||||
@@ -67,6 +70,7 @@ export class InteractionKernel {
|
||||
seen_envelope_ids: this.seenIDs.size,
|
||||
agent_presence: [...this.presenceByActor.values()].map(presence => ({ ...presence })),
|
||||
tool_calls: this.toolCalls.snapshot(),
|
||||
tasks: this.tasks.snapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,12 +94,21 @@ export class InteractionKernel {
|
||||
this.toolCalls.restore(records, now);
|
||||
}
|
||||
|
||||
public requestTaskCancel(operationID: string, updatedAt: string) {
|
||||
return this.tasks.requestCancel(operationID, updatedAt);
|
||||
}
|
||||
|
||||
public restoreTasks(records: readonly TaskRecord[]): void {
|
||||
this.tasks.restore(records);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
this.tasks.reset();
|
||||
}
|
||||
|
||||
private remember(id: string): void {
|
||||
@@ -120,6 +133,10 @@ export class InteractionKernel {
|
||||
this.toolCalls.complete(item.result, updatedAt);
|
||||
return;
|
||||
}
|
||||
if (item.kind === "progress" && item.task && item.envelope) {
|
||||
this.tasks.receive(item.task, item.envelope.conversation_id, updatedAt);
|
||||
return;
|
||||
}
|
||||
if (item.kind !== "agent-status" || !item.envelope) return;
|
||||
const actor = item.envelope.sender;
|
||||
const actorKey = `${actor.kind}:${actor.id}`;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TaskStateMachine } from "./task-state";
|
||||
|
||||
const TIME = "2026-08-03T09:00:00.000Z";
|
||||
|
||||
describe("TaskStateMachine", () => {
|
||||
it("projects repeated progress updates into one task record", () => {
|
||||
const tasks = new TaskStateMachine();
|
||||
tasks.receive({ operation_id: "task_001", title: "Generate report", percent: 20, status: "running", cancellable: true }, "conversation_001", TIME);
|
||||
tasks.receive({ operation_id: "task_001", title: "Generate report", percent: 80, status: "running", cancellable: true }, "conversation_001", "2026-08-03T09:01:00.000Z");
|
||||
|
||||
expect(tasks.snapshot()).toEqual([expect.objectContaining({ operation_id: "task_001", percent: 80, status: "running" })]);
|
||||
});
|
||||
|
||||
it("records one local cancellation request and lets the Agent close it as cancelled", () => {
|
||||
const tasks = new TaskStateMachine();
|
||||
tasks.receive({ operation_id: "task_002", title: "Export", percent: 40, status: "running", cancellable: true }, "conversation_001", TIME);
|
||||
|
||||
expect(tasks.requestCancel("task_002", "2026-08-03T09:01:00.000Z")).toMatchObject({ disposition: "accepted", record: { cancel_requested_at: "2026-08-03T09:01:00.000Z" } });
|
||||
expect(tasks.requestCancel("task_002", "2026-08-03T09:02:00.000Z").disposition).toBe("invalid_transition");
|
||||
tasks.receive({ operation_id: "task_002", title: "Export", percent: 40, status: "cancelled", cancellable: false }, "conversation_001", "2026-08-03T09:03:00.000Z");
|
||||
|
||||
expect(tasks.get("task_002")).toMatchObject({ status: "cancelled" });
|
||||
expect(tasks.get("task_002")).not.toHaveProperty("cancel_requested_at");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { TaskProgress, TaskStatus } from "../protocol";
|
||||
|
||||
export type TaskRecord = TaskProgress & {
|
||||
conversation_id: string;
|
||||
updated_at: string;
|
||||
cancel_requested_at?: string;
|
||||
};
|
||||
|
||||
export type TaskTransition =
|
||||
| { disposition: "accepted"; record: TaskRecord }
|
||||
| { disposition: "missing" | "invalid_transition"; record?: TaskRecord };
|
||||
|
||||
/**
|
||||
* Platform-neutral task projection. It has no DOM, storage, transport, or
|
||||
* side-effect handler. M1-04 records a user cancellation request locally;
|
||||
* M1-06 later owns durable delivery to the Agent.
|
||||
*/
|
||||
export class TaskStateMachine {
|
||||
private readonly tasks = new Map<string, TaskRecord>();
|
||||
|
||||
public receive(update: TaskProgress, conversationID: string, updatedAt: string): TaskRecord {
|
||||
const current = this.tasks.get(update.operation_id);
|
||||
const next: TaskRecord = {
|
||||
...update,
|
||||
conversation_id: conversationID,
|
||||
updated_at: updatedAt,
|
||||
...(current?.cancel_requested_at && update.status !== "cancelled" ? { cancel_requested_at: current.cancel_requested_at } : {}),
|
||||
};
|
||||
this.tasks.set(next.operation_id, next);
|
||||
return copy(next);
|
||||
}
|
||||
|
||||
public requestCancel(operationID: string, updatedAt: string): TaskTransition {
|
||||
const record = this.tasks.get(operationID);
|
||||
if (!record) return { disposition: "missing" };
|
||||
if (!record.cancellable || isTerminal(record.status) || record.cancel_requested_at) return { disposition: "invalid_transition", record: copy(record) };
|
||||
record.cancel_requested_at = updatedAt;
|
||||
record.updated_at = updatedAt;
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
|
||||
public get(operationID: string): TaskRecord | undefined {
|
||||
const record = this.tasks.get(operationID);
|
||||
return record ? copy(record) : undefined;
|
||||
}
|
||||
|
||||
public snapshot(): readonly TaskRecord[] { return [...this.tasks.values()].map(copy); }
|
||||
public reset(): void { this.tasks.clear(); }
|
||||
public restore(records: readonly TaskRecord[]): void {
|
||||
this.tasks.clear();
|
||||
for (const record of records) {
|
||||
if (!isRestoreable(record) || this.tasks.has(record.operation_id)) continue;
|
||||
this.tasks.set(record.operation_id, copy(record));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminal(status: TaskStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled"; }
|
||||
|
||||
function isRestoreable(value: unknown): value is TaskRecord {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const record = value as Partial<TaskRecord>;
|
||||
return typeof record.operation_id === "string" && Boolean(record.operation_id)
|
||||
&& typeof record.conversation_id === "string" && Boolean(record.conversation_id)
|
||||
&& typeof record.title === "string" && typeof record.percent === "number" && Number.isFinite(record.percent)
|
||||
&& TASK_STATUS_VALUES.has(record.status as TaskStatus) && typeof record.cancellable === "boolean"
|
||||
&& typeof record.updated_at === "string" && !Number.isNaN(Date.parse(record.updated_at));
|
||||
}
|
||||
|
||||
const TASK_STATUS_VALUES = new Set<TaskStatus>(["running", "waiting", "completed", "failed", "cancelled"]);
|
||||
function copy(record: TaskRecord): TaskRecord { return { ...record }; }
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ToolCallRequest,
|
||||
} from "../protocol";
|
||||
import type { ToolCallRecord } from "./tool-call-state";
|
||||
import type { TaskRecord } from "./task-state";
|
||||
|
||||
export type ToolInteractionHandlers = {
|
||||
submit: (callID: string, submission: JsonObject) => boolean;
|
||||
@@ -21,6 +22,11 @@ export type ToolInteractionHandlers = {
|
||||
clearDraft: (callID: string) => void;
|
||||
};
|
||||
|
||||
export type TaskInteractionHandlers = {
|
||||
requestCancel: (operationID: string) => boolean;
|
||||
read: (operationID: string) => TaskRecord | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Trusted presentation context for the first-party chat shell. It owns only
|
||||
* DOM nodes and display-local state; it deliberately has no Store, Transport,
|
||||
@@ -29,17 +35,20 @@ export type ToolInteractionHandlers = {
|
||||
export class TrustedDOMRendererContext {
|
||||
private thinking: HTMLElement | undefined;
|
||||
private readonly userRows = new Map<string, HTMLElement>();
|
||||
private readonly taskCards = new Map<string, { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }>();
|
||||
private readonly expirationTimers = new Set<number>();
|
||||
|
||||
public constructor(
|
||||
private readonly messages: HTMLElement,
|
||||
private readonly presence: HTMLElement,
|
||||
private readonly toolInteractions?: ToolInteractionHandlers,
|
||||
private readonly taskInteractions?: TaskInteractionHandlers,
|
||||
) {}
|
||||
|
||||
public reset(): void {
|
||||
this.thinking = undefined;
|
||||
this.userRows.clear();
|
||||
this.taskCards.clear();
|
||||
for (const timer of this.expirationTimers) window.clearTimeout(timer);
|
||||
this.expirationTimers.clear();
|
||||
}
|
||||
@@ -63,6 +72,14 @@ export class TrustedDOMRendererContext {
|
||||
}
|
||||
|
||||
public renderProgress(item: Extract<ConversationItem, { kind: "progress" }>): void {
|
||||
if (item.task) {
|
||||
this.renderTaskProgress(this.taskInteractions?.read(item.task.operation_id) ?? {
|
||||
...item.task,
|
||||
conversation_id: item.envelope?.conversation_id ?? "",
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const percent = Math.max(0, Math.min(100, item.percent));
|
||||
this.renderSystemMessage(`${item.title || "Agent 任务"}:${percent}%${item.status ? ` · ${item.status}` : ""}`);
|
||||
}
|
||||
@@ -164,6 +181,47 @@ export class TrustedDOMRendererContext {
|
||||
return row;
|
||||
}
|
||||
|
||||
private renderTaskProgress(task: TaskRecord): void {
|
||||
const existing = this.taskCards.get(task.operation_id);
|
||||
if (existing) {
|
||||
this.updateTaskCard(existing, task);
|
||||
return;
|
||||
}
|
||||
const card = document.createElement("article");
|
||||
card.className = "task-card";
|
||||
card.dataset.operationId = task.operation_id;
|
||||
const title = document.createElement("strong");
|
||||
const progress = document.createElement("progress");
|
||||
progress.max = 100;
|
||||
const status = document.createElement("small");
|
||||
status.className = "task-card-status";
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "secondary-action";
|
||||
cancel.textContent = "请求取消";
|
||||
cancel.addEventListener("click", () => {
|
||||
if (!this.taskInteractions?.requestCancel(task.operation_id)) return;
|
||||
const current = this.taskInteractions.read(task.operation_id);
|
||||
if (current) this.updateTaskCard(this.taskCards.get(task.operation_id)!, current);
|
||||
});
|
||||
const entry = { card, title, progress, status, cancel };
|
||||
card.append(title, progress, status, cancel);
|
||||
this.taskCards.set(task.operation_id, entry);
|
||||
this.updateTaskCard(entry, task);
|
||||
this.messages.append(card);
|
||||
this.scrollLatest();
|
||||
}
|
||||
|
||||
private updateTaskCard(entry: { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }, task: TaskRecord): void {
|
||||
entry.card.dataset.status = task.status;
|
||||
entry.title.textContent = task.title;
|
||||
entry.progress.value = Math.max(0, Math.min(100, task.percent));
|
||||
const requested = task.cancel_requested_at !== undefined && task.status !== "cancelled";
|
||||
entry.status.textContent = requested ? `取消请求已记录 · ${task.percent}%` : `${task.status} · ${task.percent}%`;
|
||||
entry.cancel.hidden = !task.cancellable || task.status === "completed" || task.status === "failed" || task.status === "cancelled";
|
||||
entry.cancel.disabled = requested;
|
||||
}
|
||||
|
||||
private renderActionGroup(call: ToolCallRequest, group: ActionGroup, record?: ToolCallRecord): void {
|
||||
const card = document.createElement("article");
|
||||
card.className = "action-group-card";
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user