diff --git a/CHANGELOG.md b/CHANGELOG.md index 108f5a1..a715c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,145 @@ # Changelog +## 0.3.2 - 2026-08-21 + +Release candidate for the durable Interaction/v1 web experience. This is the +reviewed source that replaces the internal beta sequence. Publication remains +blocked on the current-digest cross-repository preproduction gate and uses the +protected release workflow only after that gate is green. + +- Unify the Hosted UI composer with Studio's compact attachment, approval, + model, Goal, and Plan controls. The runtime's ordinary agent loop remains + internal rather than appearing as a third user-selectable mode; a future + eval-driven improvement loop must advertise its own honest capability. +- Simplify the session list: completed runs no longer expose raw status text; + active sessions use a small activity ring and subtle background, while failed + sessions use a restrained error dot. +- Keep RuntimeCapabilityMatrix decoding executable before the TypeScript build, + so the same fail-closed contract projection is covered by both browser tests + and the npm publication workflow's Node compatibility gate. + +## 0.3.2-beta.5 - 2026-08-21 + +When the composer Interaction tray owns a pending approval, its tool-history +row is now strictly read-only. This removes the second legacy approve/reject +entry point while preserving the command arguments and terminal audit result. + +## 0.3.2-beta.4 - 2026-08-21 + +Fixes the legacy Responses approval bridge: a pending approval is retained in +the read-only tool history and also normalized into the unified Interaction +tray immediately above the composer. The submit still uses the compatible +Responses approval transport when Interaction/v1 is unavailable. + +## 0.3.2-beta.3 - 2026-08-20 + +Adds package-internal release provenance. Hosted UI and the preproduction gate +can verify the resolved tarball's source basis and Interaction/v1 contract +digest instead of trusting an external evidence label. + +## 0.3.2-beta.2 - 2026-08-20 + +Rebuilt the immutable beta artifact from the complete Interaction/v1 source. +This supersedes `0.3.2-beta.1`, whose vendored Hosted UI tarball predated the +two fixes below. + +- Treat a successful `SubmitInteraction` receipt as durable acceptance, not as + proof that the framework execution has already completed its resolution. +- Add the queue tray and read-only historical interaction anchors to the + published artifact, rather than leaving them only on the Web source branch. + +## 0.3.2-beta.1 - 2026-08-19 + +> Unifies durable human-in-the-loop decisions behind one `Interaction/v1` +> client. Counterpart of the agent-kernel Interaction/v1 contract: pending +> approvals, structured inputs, and AG-UI interrupts normalize to a single +> Interaction shape with one submit path (`SubmitInteraction`) with +> revision CAS, idempotency keys, and first-wins terminal semantics. + +### Interaction/v1 (headless core) + +- New `src/core/interaction/` module: `InteractionClientImpl` + shared + `InteractionStore` with strict first-wins semantics (a terminal record + never regresses to pending), revision-based optimistic concurrency, and + an idempotency-key double-submit guard. +- Transport adapters normalize three sources into the same Interaction: + Interaction/v1 SessionEvents (`interaction.requested` / `.resolved`, + rejection is `resolved.outcome="rejected"`, not a fifth event type), + Responses `mcp_approval_request`, and AG-UI interrupts. Components never + branch on the approval protocol. +- New public API: `POST /agentengine/api/v1/SubmitInteraction` + (`submitInteraction` on `ApiFacade`), receipt decoded with the canonical + agent-kernel/v1 strict decoder. Pure `/v1/responses` clients keep the + official `mcp_approval_response` path. +- Refresh/replay: session load replays recent durable SessionEvents into + the store so a pending Interaction is restored without creating a second + request; a double click, a stale tab, or a replayed history can never + duplicate a decision. + +### UI + +- `InteractionTray` renders above the composer with the current pending + item, pending count, and queue navigation; the composer input is + visually subordinated while a decision is pending. +- `InteractionHistoryAnchor` replaces interactive historical approval + buttons with a read-only status retaining actor, decision time, + outcome, and a redacted response summary. +- A2UI production wire locked to `0.9.1` with catalog digest validation + (`validateA2uiPresentation`). Unknown wire versions or catalog + mismatches fall back to the canonical JSON schema form, then to plain + approve/reject controls. A validation failure is never mapped to an + approval. +- Expiry: interactions with a past `expires_at` disable submission + client-side and surface an expired notice. + +### Compatibility + +- Old servers without the `interaction_v1` capability keep the 0.3.1 + Responses (`mcp_approval_response`) and AG-UI (`resumeAguiInterrupt`) + callbacks; the fallback is isolated in the adapters and the shared UI is + unchanged. +- Public runtime bundle exports `InteractionClientImpl`, `Interaction`, + adapters, and `interactionIdempotencyKey`. + +### Supply chain + +- Refresh the lockfile's production transitive dependencies for the `0.3.2` + candidate: Mermaid/DOMPurify and PostCSS/nanoid now resolve to the patched + releases verified by `npm audit --omit=dev`. + +## 0.3.1 - 2026-08-12 + +> Identity-aware runtime item reducer. Counterpart of the KsADK `0.8.1` +> canonical RuntimeEvent(schema_version=2) release. Hosted UI and Studio must +> ship this version (or later) to keep stream/replay output consistent with the +> Python canonical pipeline. + +### Stream / session reducer + +- Introduce `RuntimeItemReducer` (`src/core/stream/runtime-items.ts`) as the + single canonical store for streaming output. It reduces identity-addressed + item operations into a per-run projection keyed by + `runId / scopeId / itemId / partId`, replacing the legacy v1 heuristic dedup + (`lastText` / per-agent accumulator / `startswith` / suffix overlap / + text hash). +- Ingress adapters normalize three sources into the same item operations: + Responses SSE output item ids, session events `Metadata.RuntimeItem`, and + legacy server events (no identity) via synthesized + `${invocationId}:legacy-assistant`. `assistant_stream_snapshot` maps to + replace, `assistant_message` maps to complete. Nothing inspects body text + to decide identity. + +### Compatibility + +- Consumers tracking the KsADK `0.8.1` canonical RuntimeEvent must use this + identity-aware version. `0.3.0` used the v1 heuristic reducer and will + duplicate or drop output when paired with canonical v2 producers. + ## 0.3.0 - 2026-07-29 +> Published baseline for Hosted runtime transport and the KsADK `0.8.0` +> web integration. + ### Hosted runtime transport - Add the official `@ag-ui/client` transport and capability-driven runtime diff --git a/RELEASE_PROVENANCE.json b/RELEASE_PROVENANCE.json new file mode 100644 index 0000000..f87558f --- /dev/null +++ b/RELEASE_PROVENANCE.json @@ -0,0 +1,7 @@ +{ + "schema_version": 1, + "package": "@kingsoftcloud/ksadk-web", + "version": "0.3.2", + "source_commit": "2136448e038b4d8c475fa20e4722252b1ddb2ebc", + "interaction_contract_digest": "47e1003e03d97abeba232cc3e03a14b9cbcf78b1109870ccd2ce371f073b6211" +} diff --git a/e2e/agui.spec.mjs b/e2e/agui.spec.mjs index fd30c3c..62004de 100644 --- a/e2e/agui.spec.mjs +++ b/e2e/agui.spec.mjs @@ -45,7 +45,10 @@ function bootstrap() { Approval: true, Thinking: true, StopRun: true, - ResumeRun: true, + ResumeRun: false, + // The direct AG-UI HTTP stream is browser-attached: it is only + // selected when the agent does not require resumable runs. + RunLifecycle: { Enabled: false }, }, HostedChat: { PreferredTransport: 'ag-ui', @@ -182,12 +185,19 @@ test('projects AG-UI activity, resumes approval, and replays terminal state', as await page.goto('/'); await expect(page.getByText('AG-UI Fixture')).toBeVisible(); - const composer = page.getByPlaceholder('发送消息...'); + const composer = page.locator('textarea[placeholder^="发送消息"]'); await composer.fill('请写入 demo.txt'); await composer.press('Enter'); await expect(page.getByText('E2E 状态卡')).toBeVisible(); - await expect(page.locator('summary').filter({ hasText: '等待审批' })).toBeVisible(); + // 0.3.2: the AG-UI interrupt is normalized into one durable + // Interaction — the tray carries the decision UI, the history anchor + // is read-only. + await expect(page.getByTestId('interaction-tray')).toBeVisible(); + await expect(page.getByTestId('interaction-history-anchor')).toHaveAttribute( + 'data-interaction-status', + 'pending', + ); await expect(page.getByRole('button', { name: '批准并继续' })).toBeVisible(); await page.getByRole('button', { name: '批准并继续' }).click(); @@ -200,7 +210,10 @@ test('projects AG-UI activity, resumes approval, and replays terminal state', as }, ]); await expect(page.getByText('审批已完成。')).toBeVisible(); - await expect(page.locator('summary').filter({ hasText: '已批准' })).toBeVisible(); + await expect(page.getByTestId('interaction-history-anchor')).toHaveAttribute( + 'data-interaction-status', + 'resolved', + ); fixture.approved = true; await page.reload(); diff --git a/e2e/interaction.spec.ts b/e2e/interaction.spec.ts new file mode 100644 index 0000000..839c7f3 --- /dev/null +++ b/e2e/interaction.spec.ts @@ -0,0 +1,668 @@ +import { expect, test } from '@playwright/test'; + +/** + * Interaction/v1 durable-interaction e2e (Web 0.3.2). + * + * The fixture is a Responses-transport server that advertises + * `interaction_v1`. Pending interactions arrive as durable + * SessionEvents (replayed via ListSessionEvents on session load), and + * every decision is a single POST /agentengine/api/v1/SubmitInteraction + * with revision CAS and an idempotency key. + */ + +const SESSION_ID = 'session-interaction-e2e'; +const AGENT_ID = 'fixture-agent'; + +function envelope(data) { + return { Code: 0, Message: 'Success', Data: data }; +} + +function sse(events) { + return events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''); +} + +function runtimeCapabilityMatrix() { + const native = { supported: true, mode: 'native' }; + return { + schema_version: 1, + cancel: native, + pause: native, + resume: native, + submit_interaction: native, + attach: native, + steer: native, + inject: native, + checkpoint: native, + durable_restore: native, + goal: native, + loop: native, + plan: native, + }; +} + +function bootstrap(interactionV1 = true, executionModes = false) { + return { + Agent: { AgentId: AGENT_ID, Name: 'Interaction Fixture', Framework: 'ksadk' }, + ApiFormats: ['responses'], + Capabilities: { + Attachments: false, + WorkspaceFiles: false, + Approval: true, + Thinking: false, + StopRun: true, + ResumeRun: false, + ...(interactionV1 ? { interaction_v1: { enabled: true } } : {}), + ...(executionModes ? { RuntimeCapabilityMatrix: runtimeCapabilityMatrix() } : {}), + }, + HostedChat: { + PreferredTransport: 'responses', + Transports: [ + { + Protocol: 'responses', + Runtime: 'ksadk', + Endpoint: '/v1/responses', + Version: 'v1', + Capabilities: { A2UI: false, Interrupt: true, Cancel: true }, + }, + ], + }, + Model: { id: 'fixture-model', display_name: 'Fixture Model' }, + }; +} + +function requestedEvent(overrides = {}) { + return { + EventId: `evt-${overrides.interactionId || 'int-1'}-req`, + SessionId: SESSION_ID, + SeqId: 10, + InvocationId: 'inv-1', + EventType: 'interaction.requested', + Content: { + interaction: { + interaction_id: overrides.interactionId || 'int-1', + session_id: SESSION_ID, + run_id: 'run-1', + kind: overrides.kind || 'approval', + revision: 1, + created_at: '2026-08-19T00:00:00Z', + ...(overrides.expiresAt ? { expires_at: overrides.expiresAt } : {}), + ...(overrides.requestSchema ? { request_schema: overrides.requestSchema } : {}), + ...(overrides.presentation ? { presentation: overrides.presentation } : {}), + ...(overrides.message ? { message: overrides.message } : {}), + ...(overrides.title ? { title: overrides.title } : {}), + }, + }, + }; +} + +function resolvedEvent(interactionId = 'int-1', outcome = 'approved') { + return { + EventId: `evt-${interactionId}-res`, + SessionId: SESSION_ID, + SeqId: 11, + InvocationId: 'inv-1', + EventType: 'interaction.resolved', + Content: { + interaction_id: interactionId, + outcome, + actor: 'user', + resolved_at: '2026-08-19T00:01:00Z', + }, + }; +} + +/** + * Installs the fixture on a page. `state` is shared across pages so two + * tabs hit the same server truth. + */ +async function installFixture(page, state, options = {}) { + await page.route('**/agentengine/api/v1/**', async (route) => { + const action = new URL(route.request().url()).pathname.split('/').pop(); + + if (action === 'SubmitInteraction') { + const body = route.request().postDataJSON(); + state.submits.push(body); + const alreadyResolved = state.resolvedIds.has(body.InteractionId); + const receipt = alreadyResolved + ? { + schema_version: 1, + command_id: `cmd-${state.submits.length}`, + status: 'rejected', + error: { + code: 'interaction_already_resolved', + message: 'first-wins: another submission resolved this interaction', + retryable: false, + }, + } + : { + schema_version: 1, + command_id: `cmd-${state.submits.length}`, + status: 'accepted', + }; + if (!alreadyResolved) { + state.resolvedIds.add(body.InteractionId); + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(envelope(receipt)), + }); + return; + } + + if (action === 'RunAgent') { + state.runs?.push(route.request().postDataJSON()); + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: sse([ + { type: 'response.completed', status: 'completed', output_text: '运行完成。' }, + ]), + }); + return; + } + + const events = typeof state.events === 'function' ? state.events() : state.events; + const payloadByAction = { + GetAgentUiBootstrap: bootstrap(options.interactionV1 !== false, options.executionModes === true), + ListSessions: { + Sessions: state.sessionCreated + ? [{ SessionId: SESSION_ID, AgentId: AGENT_ID, Title: 'Interaction fixture' }] + : [], + Total: state.sessionCreated ? 1 : 0, + Page: 1, + PageSize: 30, + }, + ListAgentModels: { Models: [{ id: 'fixture-model', display_name: 'Fixture Model' }] }, + CreateSession: { Session: { SessionId: SESSION_ID, AgentId: AGENT_ID } }, + GetSession: { Session: { SessionId: SESSION_ID, AgentId: AGENT_ID, ActiveRunStatus: '' } }, + ListSessionEvents: { Events: events, Total: events.length }, + ListSessionMessages: { + Messages: typeof state.history === 'function' ? state.history() : (state.history || []), + LatestSeqId: 0, + HasMore: false, + NextCursor: null, + }, + ListSessionCheckpoints: { Checkpoints: [] }, + ListToolReceipts: { ToolReceipts: [] }, + GetResponseFeedback: null, + }; + if (action === 'CreateSession') state.sessionCreated = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(envelope(payloadByAction[action] ?? {})), + }); + }); +} + +function historyWithApproval(status = 'paused') { + return () => [ + { + MessageId: 'assistant-approval', + Role: 'assistant', + Content: { text: '需要人工确认。' }, + ToolEvents: [ + { + Name: 'write_file', + Args: { path: 'demo.txt' }, + Status: status, + ApprovalRequestId: 'int-1', + Protocol: 'responses', + }, + ], + }, + ]; +} + +async function createSession(page) { + await page.goto('/'); + await expect(page.getByText('Interaction Fixture')).toBeVisible(); + const composer = page.getByPlaceholder('发送消息…'); + await composer.fill('hello'); + await composer.press('Enter'); + await expect(page.getByText('运行完成。')).toBeVisible(); +} + +test('approve sends exactly one SubmitInteraction and replay never resubmits', async ({ page }) => { + const state = { + submits: [], + resolvedIds: new Set(), + sessionCreated: false, + events: () => [requestedEvent()], + }; + state.history = historyWithApproval(); + await installFixture(page, state); + await createSession(page); + + // Refresh: pending interaction restored from durable history. + await page.reload(); + const tray = page.getByTestId('interaction-tray'); + await expect(tray).toBeVisible(); + await expect(page.getByTestId('interaction-tray-title')).toHaveText('人工确认'); + expect(state.submits).toHaveLength(0); + + // Double click: exactly one submit leaves the client. + const approve = page.getByTestId('interaction-approve'); + await approve.dblclick(); + await expect.poll(() => state.submits.length).toBe(1); + expect(state.submits[0]).toMatchObject({ + AgentId: AGENT_ID, + SessionId: SESSION_ID, + InteractionId: 'int-1', + ExpectedRevision: 1, + Action: 'approve', + IdempotencyKey: 'interaction:int-1:revision-1', + }); + expect(state.submits[0].Response).toMatchObject({ decision: 'approve' }); + + // The accepted receipt only proves the command entered the durable + // Inbox — the UI stays "resolving" until the authoritative + // interaction.resolved SessionEvent arrives. + await expect(tray).toHaveAttribute('data-interaction-status', 'resolving'); + await expect(page.getByText('正在提交,请稍候…')).toBeVisible(); + + // Replay after resolution (terminal fact carried by the SessionEvent + // stream): history shows a read-only anchor, no second request ever + // leaves the client. + state.events = () => [requestedEvent(), resolvedEvent('int-1', 'approved')]; + state.history = historyWithApproval('approved'); + await page.reload(); + const anchor = page.getByTestId('interaction-history-anchor'); + await expect(anchor).toBeVisible(); + await expect(anchor).toHaveAttribute('data-interaction-status', 'resolved'); + await expect(page.getByText('操作人:user')).toBeVisible(); + await expect(page.getByText('已同意')).toBeVisible(); + await expect(tray).toHaveCount(0); + expect(state.submits).toHaveLength(1); +}); + +test('reject stays resolving after the receipt and resolves on the terminal event', async ({ page }) => { + const state = { + submits: [], + resolvedIds: new Set(), + sessionCreated: false, + events: () => [requestedEvent()], + }; + state.history = historyWithApproval(); + await installFixture(page, state); + await createSession(page); + + await page.reload(); + const tray = page.getByTestId('interaction-tray'); + await expect(tray).toBeVisible(); + await page.getByTestId('interaction-reject').click(); + + await expect.poll(() => state.submits.length).toBe(1); + expect(state.submits[0]).toMatchObject({ + InteractionId: 'int-1', + Action: 'reject', + ExpectedRevision: 1, + }); + expect(state.submits[0].Response).toMatchObject({ decision: 'reject' }); + // Receipt accepted, terminal state not yet authoritative. + await expect(tray).toHaveAttribute('data-interaction-status', 'resolving'); + + // Terminal fact arrives via the replayed event stream. + state.events = () => [requestedEvent(), resolvedEvent('int-1', 'rejected')]; + state.history = historyWithApproval('rejected'); + await page.reload(); + await expect(page.getByTestId('interaction-history-anchor')).toHaveAttribute( + 'data-interaction-status', + 'resolved', + ); + await expect(tray).toHaveCount(0); + expect(state.submits).toHaveLength(1); +}); + +test('structured form submits the full response payload', async ({ page }) => { + const state = { + submits: [], + resolvedIds: new Set(), + sessionCreated: false, + events: () => [ + requestedEvent({ + interactionId: 'int-form', + kind: 'structured_input', + title: '部署确认', + message: '请选择部署目标并填写备注。', + requestSchema: { + type: 'object', + properties: { + deploy_target: { type: 'string', title: '部署目标', enum: ['pre-online', 'online'] }, + note: { type: 'string', title: '备注' }, + }, + required: ['deploy_target'], + }, + }), + ], + }; + await installFixture(page, state); + await createSession(page); + + await page.reload(); + await expect(page.getByTestId('interaction-schema-form')).toBeVisible(); + await page.getByTestId('interaction-field-deploy_target').selectOption('online'); + await page.getByTestId('interaction-field-note').fill('发布 0.3.2'); + await page.getByTestId('interaction-submit').click(); + + await expect.poll(() => state.submits.length).toBe(1); + expect(state.submits[0]).toMatchObject({ + InteractionId: 'int-form', + Action: 'submit', + }); + expect(state.submits[0].Response).toEqual({ + deploy_target: 'online', + note: '发布 0.3.2', + }); +}); + +test('two tabs: first-wins receipt, second tab never duplicates the decision', async ({ browser }) => { + const context = await browser.newContext(); + const state = { + submits: [], + resolvedIds: new Set(), + sessionCreated: false, + events: () => [requestedEvent()], + }; + + state.history = historyWithApproval(); + const tabA = await context.newPage(); + await installFixture(tabA, state); + await createSession(tabA); + await tabA.reload(); + await expect(tabA.getByTestId('interaction-tray')).toBeVisible(); + + // Stale tab B still sees the pending request (no sync yet). + const tabB = await context.newPage(); + await installFixture(tabB, state); + await tabB.goto('/'); + await tabB.reload(); + await expect(tabB.getByTestId('interaction-tray')).toBeVisible(); + + await tabA.getByTestId('interaction-approve').click(); + await expect.poll(() => state.submits.length).toBe(1); + expect(state.submits[0].Action).toBe('approve'); + // First tab: accepted receipt keeps the tray resolving. + await expect(tabA.getByTestId('interaction-tray')).toHaveAttribute( + 'data-interaction-status', + 'resolving', + ); + + // Second tab submits the same revision: the server is the authority and + // returns the first-wins rejection receipt. + await tabB.getByTestId('interaction-reject').click(); + await expect.poll(() => state.submits.length).toBe(2); + expect(state.resolvedIds.has('int-1')).toBe(true); + // The rejected receipt is a failed submit — the second tab must show + // the failure, never a resolved/cancelled state. + const trayB = tabB.getByTestId('interaction-tray'); + await expect(trayB).toHaveAttribute('data-interaction-status', 'failed'); + await expect(tabB.getByTestId('interaction-tray-error')).toBeVisible(); + await expect(tabB.getByText(/interaction_already_resolved/)).toBeVisible(); + + // After replaying the authoritative terminal fact, no third request can + // be produced even by clicking again. + state.events = () => [requestedEvent(), resolvedEvent('int-1', 'approved')]; + state.history = historyWithApproval('approved'); + await tabB.reload(); + await expect(tabB.getByTestId('interaction-history-anchor')).toBeVisible(); + await expect(tabB.getByTestId('interaction-tray')).toHaveCount(0); + expect(state.submits).toHaveLength(2); +}); + +test('expired interactions disable submission client-side', async ({ page }) => { + const state = { + submits: [], + resolvedIds: new Set(), + sessionCreated: false, + events: () => [ + requestedEvent({ expiresAt: '2026-08-18T00:00:00Z' }), + ], + }; + await installFixture(page, state); + await createSession(page); + + await page.reload(); + await expect(page.getByTestId('interaction-tray-expired')).toBeVisible(); + const approve = page.getByTestId('interaction-approve'); + await expect(approve).toBeDisabled(); + await page.evaluate((selector) => { + document.querySelector(selector)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }, '[data-testid="interaction-approve"]'); + await page.waitForTimeout(300); + expect(state.submits).toHaveLength(0); +}); + +test('unknown A2UI wire version falls back safely and never approves', async ({ page }) => { + const state = { + submits: [], + resolvedIds: new Set(), + sessionCreated: false, + events: () => [ + requestedEvent({ + interactionId: 'int-a2ui', + presentation: { + a2ui: { + wire_version: '1.0', + catalog_digest: 'unknown-digest', + messages: [{ id: 'm1', inputSchema: { type: 'object', properties: { hint: { type: 'string' } } } }], + }, + }, + }), + ], + }; + await installFixture(page, state); + await createSession(page); + + await page.reload(); + await expect(page.getByTestId('interaction-tray')).toBeVisible(); + // Unknown wire version + catalog: never rendered through A2UI; the JSON + // schema form fallback appears instead. + await expect(page.getByTestId('interaction-a2ui-surface')).toHaveCount(0); + await expect(page.getByTestId('interaction-schema-form')).toBeVisible(); + + await page.getByTestId('interaction-field-hint').fill('ok'); + await page.getByTestId('interaction-submit').click(); + await expect.poll(() => state.submits.length).toBe(1); + expect(state.submits[0].Action).toBe('submit'); + expect(state.submits[0].Response).toEqual({ hint: 'ok' }); +}); + +test('servers without interaction_v1 keep the 0.3.1 Responses approval behavior', async ({ page }) => { + const state = { + submits: [], + resolvedIds: new Set(), + sessionCreated: false, + events: () => [], + }; + await installFixture(page, state, { interactionV1: false }); + await createSession(page); + + // Old server: no interaction_v1 capability, no SubmitInteraction calls. + await page.reload(); + await expect(page.getByTestId('interaction-tray')).toHaveCount(0); + expect(state.submits).toHaveLength(0); +}); + +test('multiple pending interactions queue in one tray with count badge and switching', async ({ page }) => { + const state = { + submits: [], + resolvedIds: new Set(), + sessionCreated: false, + events: () => [ + requestedEvent({ interactionId: 'int-q1', title: '删除文件', message: '确认删除 demo.txt?' }), + requestedEvent({ interactionId: 'int-q2', title: '执行命令', message: '确认执行 rm -rf /tmp/cache?' }), + ], + }; + await installFixture(page, state); + await createSession(page); + await page.reload(); + + // One tray, one expanded form at a time — never stacked large forms. + const trays = page.getByTestId('interaction-tray'); + await expect(trays).toHaveCount(1); + await expect(trays).toHaveAttribute('data-interaction-count', '2'); + await expect(page.getByTestId('interaction-tray-count')).toHaveText('1/2'); + await expect(page.getByTestId('interaction-tray-title')).toHaveText('删除文件'); + // Only the current item's action buttons are rendered. + await expect(page.getByTestId('interaction-approve')).toHaveCount(1); + + // Switch to the second item. + await page.getByTestId('interaction-tray-next').click(); + await expect(page.getByTestId('interaction-tray-count')).toHaveText('2/2'); + await expect(page.getByTestId('interaction-tray-title')).toHaveText('执行命令'); + await expect(page.getByTestId('interaction-tray-prev')).toBeEnabled(); + await expect(page.getByTestId('interaction-tray-next')).toBeDisabled(); +}); + +test('the confirmation tray never obscures the composer', async ({ page }) => { + const state = { + submits: [], + resolvedIds: new Set(), + sessionCreated: false, + events: () => [ + requestedEvent({ + interactionId: 'int-q1', + title: '删除文件', + message: '确认删除 demo.txt?', + requestSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }, + }), + requestedEvent({ + interactionId: 'int-q2', + title: '执行命令', + message: '确认执行 deploy.sh?', + requestSchema: { + type: 'object', + properties: { env: { type: 'string' } }, + }, + }), + ], + }; + await installFixture(page, state); + await createSession(page); + await page.reload(); + + const tray = page.getByTestId('interaction-tray'); + await expect(tray).toBeVisible(); + const composer = page.getByPlaceholder('发送消息…'); + await expect(composer).toBeVisible(); + + const trayBox = await tray.boundingBox(); + const composerBox = await composer.boundingBox(); + expect(trayBox).not.toBeNull(); + expect(composerBox).not.toBeNull(); + // Layout invariant: the tray sits strictly above the composer — no + // overlap, and the composer stays fully visible and clickable. + expect(trayBox.y + trayBox.height).toBeLessThanOrEqual(composerBox.y + 1); +}); + +test('resolved anchor expands a read-only snapshot with actor, time, and schema keys', async ({ page }) => { + const state = { + submits: [], + resolvedIds: new Set(), + sessionCreated: false, + history: [ + { + MessageId: 'assistant-snap', + Role: 'assistant', + Content: { text: '需要部署确认。' }, + ToolEvents: [ + { + Name: 'deploy', + Args: { env: 'pre-online' }, + Status: 'approved', + ApprovalRequestId: 'int-snap', + Protocol: 'responses', + }, + ], + }, + ], + events: () => [ + requestedEvent({ + interactionId: 'int-snap', + kind: 'structured_input', + title: '部署确认', + requestSchema: { + type: 'object', + properties: { + deploy_target: { type: 'string' }, + note: { type: 'string' }, + }, + required: ['deploy_target'], + }, + }), + resolvedEvent('int-snap', 'submitted'), + ], + }; + await installFixture(page, state); + await createSession(page); + await page.reload(); + + const anchor = page.getByTestId('interaction-history-anchor'); + await expect(anchor).toBeVisible(); + await expect(anchor).toHaveAttribute('data-interaction-status', 'resolved'); + + // Expand the snapshot. + await page.getByTestId('interaction-history-detail').locator('summary').click(); + const snapshot = page.getByTestId('interaction-history-snapshot'); + await expect(snapshot).toBeVisible(); + await expect(page.getByTestId('interaction-history-actor-ref')).toHaveText('user'); + await expect(page.getByTestId('interaction-history-schema-keys')).toHaveText('deploy_target、note'); + + // Read-only: no editable controls inside the anchor. + const editable = await anchor.locator('input, textarea, select, form, button').count(); + expect(editable).toBe(0); +}); + +test('typed Runtime v2 capabilities expose plan and goal without presenting the internal loop as a mode', async ({ page }) => { + const state = { + submits: [], + runs: [], + resolvedIds: new Set(), + sessionCreated: false, + events: [], + }; + await installFixture(page, state, { executionModes: true }); + await page.goto('/'); + + await page.getByRole('button', { name: '添加附件或选择执行模式' }).click(); + const menu = page.getByRole('menu', { name: '附件与执行模式' }); + await expect(menu.getByRole('menuitemradio', { name: /Agent Loop/ })).toHaveCount(0); + await expect(menu.getByRole('menuitemradio', { name: /计划模式/ })).toBeVisible(); + await expect(menu.getByRole('menuitemradio', { name: /设定目标/ })).toBeVisible(); + + await menu.getByRole('menuitemradio', { name: /计划模式/ }).click(); + const composer = page.getByPlaceholder('描述需要先规划的任务…'); + await composer.fill('先制定发布计划'); + await composer.press('Enter'); + + await expect.poll(() => state.runs.length).toBe(1); + expect(state.runs[0]).toMatchObject({ + Metadata: { + agentengine: { + collaboration_mode: 'plan', + }, + }, + }); +}); + +test('legacy capabilities do not expose Runtime v2 execution controls', async ({ page }) => { + const state = { + submits: [], + resolvedIds: new Set(), + sessionCreated: false, + events: [], + }; + await installFixture(page, state); + await page.goto('/'); + + await expect(page.getByRole('button', { name: '添加附件或选择执行模式' })).toHaveCount(0); + await expect(page.getByText('Agent Loop')).toHaveCount(0); + await expect(page.getByText('计划模式')).toHaveCount(0); + await expect(page.getByText('设定目标')).toHaveCount(0); +}); diff --git a/package-lock.json b/package-lock.json index c65c74c..2ffab9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.0", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.0", + "version": "0.3.2", "license": "Apache-2.0", "dependencies": { "@ag-ui/client": "0.0.57", @@ -2424,12 +2424,12 @@ "license": "MIT" }, "node_modules/@mermaid-js/parser": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", - "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", "license": "MIT", "dependencies": { - "@chevrotain/types": "~11.1.1" + "@chevrotain/types": "~11.1.2" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -6663,9 +6663,9 @@ "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.33.1", - "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.1.tgz", - "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.1.tgz", + "integrity": "sha512-Lr0RvH9H75y9ar8h9Toy6u4lxRSCcxUq+hHcQ26sVWo6BnaQp1gwEZOYqwuYTZhyW7npyKnNLP8oJ2p1/3OZ7g==", "license": "MIT", "engines": { "node": ">=0.10" @@ -7181,9 +7181,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debug": { @@ -7322,9 +7322,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -7674,6 +7674,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, "node_modules/fault": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/fault/-/fault-1.0.4.tgz", @@ -8550,9 +8559,9 @@ } }, "node_modules/katex": { - "version": "0.16.44", - "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.44.tgz", - "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -9407,26 +9416,27 @@ } }, "node_modules/mermaid": { - "version": "11.15.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", - "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "version": "11.17.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.0.tgz", + "integrity": "sha512-Jo9N377Wb4MSnHFPTbLi2SxFpsQl4eVHoxnW5U1Md9EazvgMp3s+4ohDxr81YNTgbn5Kj7HJ3yslrSJ52kwpbA==", "license": "MIT", "dependencies": { - "@braintree/sanitize-url": "^7.1.1", + "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.1.1", + "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.1", + "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.19", - "dompurify": "^3.3.1", + "dayjs": "^1.11.21", + "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", - "katex": "^0.16.25", + "fastdom": "1.0.12", + "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", @@ -10144,9 +10154,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -10484,9 +10494,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -10503,7 +10513,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -11292,6 +11302,12 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "license": "MIT" + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", diff --git a/package.json b/package.json index 21b65ab..277028b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.0", + "version": "0.3.2", "type": "module", "scripts": { "dev": "vite", @@ -8,10 +8,12 @@ "build:hosted": "VITE_BASE_PATH=/chat/ vite build --outDir dist-hosted", "build:lib": "vite build --config vite.lib.config.ts && tsc -p tsconfig.lib.json", "build:all": "npm run build:ksadk && npm run build:hosted && npm run build:lib", + "prepack": "npm run build:lib && npm run build:ksadk", "lint": "eslint .", "preview": "vite preview", "test": "vitest run src", "test:watch": "vitest src", + "test:e2e:interaction": "playwright test --config playwright.interaction.config.mjs", "test:e2e:agui": "playwright test --config playwright.agui.config.mjs", "test:e2e:reconnect": "playwright test --config playwright.reconnect.config.mjs", "build:ksadk": "VITE_BASE_PATH=./ vite build --outDir dist-ksadk" @@ -44,6 +46,7 @@ "dist-ksadk", "README.md", "CHANGELOG.md", + "RELEASE_PROVENANCE.json", "LICENSE", "package.json" ], diff --git a/playwright.interaction.config.mjs b/playwright.interaction.config.mjs new file mode 100644 index 0000000..9328eb6 --- /dev/null +++ b/playwright.interaction.config.mjs @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: 'interaction.spec.ts', + timeout: 30_000, + use: { + baseURL: 'http://127.0.0.1:4173', + viewport: { width: 1280, height: 900 }, + screenshot: 'only-on-failure', + }, + webServer: { + command: 'npm run dev -- --host 127.0.0.1 --port 4173 --strictPort', + url: 'http://127.0.0.1:4173', + reuseExistingServer: false, + }, +}); diff --git a/src/App.tsx b/src/App.tsx index bd818fe..19b282e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { useResponsiveViewport } from './hooks/useResponsiveViewport'; import { useBootstrap } from './hooks/useBootstrap'; import { useRunAgent } from './hooks/useRunAgent'; import { useFeedback } from './hooks/useFeedback'; +import { useInteractions } from './hooks/useInteractions.js'; import { useSessionLifecycle } from './hooks/useSessionLifecycle'; import { ConnectedSidebar } from './components/chat/ConnectedSidebar'; import { ConnectedMessageList } from './components/chat/ConnectedMessageList'; @@ -35,6 +36,7 @@ import { NativeRuntimeLauncher } from './components/native/NativeRuntimeLauncher import { WorkspacePanelContainer } from './components/workspace/WorkspacePanelContainer'; import { ApiFacadeImpl } from './core/api/facade.js'; import type { UiCapabilities } from './types/capabilities.js'; +import type { RuntimeExecutionMode } from './core/run/types.js'; import type { BootstrapWorkspaceFiles } from './types/bootstrap.js'; import type { RuntimeApiFormat } from './types/api.js'; import type { ThinkingMode } from './stores/model.js'; @@ -85,13 +87,19 @@ export function AgentWorkbench({ apiAdapter, initialSurface = 'chat', routeShell const agentFramework = useBootstrapStore((s: BootstrapStore) => s.agentFramework); const workspaceFiles = useBootstrapStore((s: BootstrapStore) => s.workspaceFiles) as BootstrapWorkspaceFiles | null; const accessMode = useBootstrapStore((s: BootstrapStore) => s.accessMode); + const bootstrapStatus = useBootstrapStore((s: BootstrapStore) => s.status); + const bootstrapErrorMessage = useBootstrapStore((s: BootstrapStore) => s.errorMessage); const workspacePanelOpen = useUIStore((s: UIStore) => s.workspacePanelOpen); const workspacePanelWidth = useUIStore((s: UIStore) => s.workspacePanelWidth); const workspacePanelFullscreen = useUIStore((s: UIStore) => s.workspacePanelFullscreen); const apiFormats = useBootstrapStore((s: BootstrapStore) => s.apiFormats) as RuntimeApiFormat[]; const uiCapabilities = useBootstrapStore((s: BootstrapStore) => s.capabilities) as UiCapabilities; const artifactVisible = useArtifactStore((s: ArtifactStore) => s.visible && Boolean(s.content)); - const queuedDraftRef = useRef>([]); + const queuedDraftRef = useRef>([]); const disconnectRunRef = useRef<(() => void) | null>(null); const { isMobile, viewportHeight } = useResponsiveViewport(); @@ -161,6 +169,7 @@ export function AgentWorkbench({ apiAdapter, initialSurface = 'chat', routeShell onRunSettled: refreshSettledRun, }); + useEffect(() => { disconnectRunRef.current = disconnectRun; }, [disconnectRun]); @@ -205,6 +214,41 @@ export function AgentWorkbench({ apiAdapter, initialSurface = 'chat', routeShell api, submitDraft, }); + // The 0.3.1 fallback callbacks are captured once inside useInteractions' + // useState initializer; route them through refs so they always invoke the + // latest engine/approval closures instead of the first render's. + const respondToApprovalRef = useRef(respondToApproval); + const respondToAguiApprovalRef = useRef(respondToAguiApproval); + useEffect(() => { + respondToApprovalRef.current = respondToApproval; + respondToAguiApprovalRef.current = respondToAguiApproval; + }, [respondToApproval, respondToAguiApproval]); + + + const { + pending: pendingInteractions, + records: interactionRecords, + respond: respondInteraction, + localCatalog, + } = useInteractions({ + agentId, + getAgentId: () => agentIdRef.current, + currentSessionId, + api, + interactionV1Enabled: Boolean(uiCapabilities.InteractionV1), + legacyResponsesApproval: (approvalRequestId, approve) => { + respondToApprovalRef.current({ approvalRequestId, approve }); + }, + legacyAguiResume: (interruptId, status, payload) => { + const engineAccepted = respondToAguiApprovalRef.current({ + interruptId, + approve: status === 'resolved', + }); + void engineAccepted; + void payload; + return engineAccepted; + }, + }); useBootstrap({ fetchSessions }); @@ -276,6 +320,44 @@ export function AgentWorkbench({ apiAdapter, initialSurface = 'chat', routeShell useUIStore.getState().setWorkspacePanelFullscreen(false); useUIStore.getState().setWorkspacePanelOpen(false); }; + + if (bootstrapStatus !== 'ready') { + const authRequired = bootstrapStatus === 'auth-required'; + const bootstrapContent = ( +
+
+
+ {bootstrapStatus === 'loading' ? '…' : '↗'} +
+

+ {bootstrapStatus === 'loading' + ? '正在连接 Agent' + : authRequired + ? '需要有效的访问链接' + : 'Agent 暂时无法连接'} +

+ {bootstrapStatus !== 'loading' ? ( + <> +

+ {bootstrapErrorMessage} + {authRequired + ? ' 请从 AgentEngine 控制台重新打开 Dashboard,或使用 agentengine dashboard open 获取临时链接。' + : ''} +

+ + + ) : null} +
+
+ ); + return RouteShell ? {bootstrapContent} : bootstrapContent; + } const handleWorkspacePanelResizeStart = (event: React.PointerEvent) => { if (workspacePanelFullscreen || isMobile) return; event.preventDefault(); @@ -379,6 +461,7 @@ export function AgentWorkbench({ apiAdapter, initialSurface = 'chat', routeShell } onResumeCheckpoint={resumeCheckpoint} onLoadOlderSessionMessages={loadOlderSessionMessages} + interactionRecords={interactionRecords} /> { + void respondInteraction(input); + }} + localCatalog={localCatalog} /> )} diff --git a/src/__tests__/agent-control.test.ts b/src/__tests__/agent-control.test.ts new file mode 100644 index 0000000..46327de --- /dev/null +++ b/src/__tests__/agent-control.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; +import { + ContractMismatchError, + decodeAgentStatusSnapshot, + decodeCapabilityMatrix, + decodeReceipt, + decodeSessionEventEnvelope, +} from '../types/agent-control.js'; +import { + SessionEventConflictError, + createSessionEventCursor, +} from '../utils/session-event-history.js'; + +const ACCEPTED_RECEIPT = { + schema_version: 1, + command_id: '4bf84e1b-f4cd-4c55-907f-2dc5e676b119', + status: 'accepted', + message_id: '11111111-2222-3333-4444-555555555555', + run_id: 'run-1', + accepted_seq: 42, +}; + +const QUEUE_FULL_RECEIPT = { + schema_version: 1, + command_id: '4bf84e1b-f4cd-4c55-907f-2dc5e676b119', + status: 'queue_full', + error: { + code: 'queue_full', + message: 'inbox depth reached limit', + retryable: true, + details: { limit: 128 }, + }, +}; + +const CAPABILITY_MATRIX = { + schema_version: 1, + cancel: { supported: true, mode: 'native' }, + pause: { supported: false, mode: 'unavailable', reason: 'runtime_pause_not_supported' }, + resume: { supported: true, mode: 'native' }, + submit_interaction: { supported: true, mode: 'native' }, + attach: { supported: true, mode: 'native' }, + steer: { supported: true, mode: 'native' }, + inject: { supported: true, mode: 'native' }, + checkpoint: { supported: false, mode: 'unavailable', reason: 'runtime_checkpoint_not_supported' }, + durable_restore: { supported: true, mode: 'native' }, +}; + +const RUNTIME_EVENT = { + schema_version: 1, + event_id: '614e828c-fed7-5202-a07d-354cfa1942a0', + session_id: 'session-1', + seq: 20, + timestamp: '2026-08-17T00:00:01Z', + family: 'runtime', + family_version: 2, + event_type: 'run.message.delta', + payload: { text: 'hello', delta_index: 0 }, + run_id: 'run-1', + causation_id: 'cmd-4bf84e1b', + correlation_id: 'corr-1', + actor_ref: 'worker-1', +}; + +describe('agent-kernel/v1 contract decoders', () => { + it('decodes an accepted receipt', () => { + const receipt = decodeReceipt(ACCEPTED_RECEIPT); + expect(receipt.status).toBe('accepted'); + expect(receipt.accepted_seq).toBe(42); + expect(receipt.extensions).toEqual({}); + }); + + it('decodes a queue_full receipt with retryable error', () => { + const receipt = decodeReceipt(QUEUE_FULL_RECEIPT); + expect(receipt.status).toBe('queue_full'); + expect(receipt.error?.retryable).toBe(true); + expect(receipt.error?.details).toEqual({ limit: 128 }); + }); + + it('rejects receipts with a wrong schema_version', () => { + expect(() => decodeReceipt({ ...ACCEPTED_RECEIPT, schema_version: 2 })).toThrow(ContractMismatchError); + }); + + it('rejects non-object receipts', () => { + expect(() => decodeReceipt('accepted')).toThrow(ContractMismatchError); + }); + + it('keeps unknown optional receipt fields in extensions', () => { + const receipt = decodeReceipt({ ...ACCEPTED_RECEIPT, queue_position: 3 }); + expect(receipt.extensions).toEqual({ queue_position: 3 }); + }); + + it('decodes the runtime capability matrix', () => { + const matrix = decodeCapabilityMatrix(CAPABILITY_MATRIX); + expect(matrix.pause).toEqual({ + supported: false, + mode: 'unavailable', + reason: 'runtime_pause_not_supported', + extensions: {}, + }); + }); + + it('decodes optional Runtime v2 goal loop and plan capabilities without changing legacy matrices', () => { + const native = { supported: true, mode: 'native' } as const; + const matrix = decodeCapabilityMatrix({ + ...CAPABILITY_MATRIX, + goal: native, + loop: native, + plan: native, + }); + + expect(matrix.goal).toMatchObject(native); + expect(matrix.loop).toMatchObject(native); + expect(matrix.plan).toMatchObject(native); + expect(matrix.extensions).toEqual({}); + + const legacy = decodeCapabilityMatrix(CAPABILITY_MATRIX); + expect(legacy.goal).toBeUndefined(); + expect(legacy.loop).toBeUndefined(); + expect(legacy.plan).toBeUndefined(); + }); + + it('rejects a capability matrix that drops required capabilities', () => { + const { checkpoint, ...partial } = CAPABILITY_MATRIX; + expect(checkpoint).toBeDefined(); + expect(() => decodeCapabilityMatrix(partial)).toThrow(ContractMismatchError); + }); + + it('decodes a session event envelope', () => { + const envelope = decodeSessionEventEnvelope(RUNTIME_EVENT); + expect(envelope.ok).toBe(true); + if (envelope.ok) { + expect(envelope.value.seq).toBe(20); + expect(envelope.value.family).toBe('runtime'); + expect(envelope.value.event_type).toBe('run.message.delta'); + } + }); + + it('keeps unknown families addressable for the cursor without displaying them', () => { + const envelope = decodeSessionEventEnvelope({ + ...RUNTIME_EVENT, + seq: 21, + family: 'job', + event_type: 'job.tick', + }); + expect(envelope.ok).toBe(true); + + const cursor = createSessionEventCursor(); + cursor.accept(RUNTIME_EVENT); + cursor.accept({ ...RUNTIME_EVENT, seq: 21, family: 'job', event_type: 'job.tick' }); + expect(cursor.lastSeq).toBe(21); + // Unknown families advance the cursor but are not projected for display. + expect(cursor.displayableEvents().map((event) => event.seq)).toEqual([20]); + }); + + it('flags envelopes that violate the session event contract', () => { + const envelope = decodeSessionEventEnvelope({ ...RUNTIME_EVENT, seq: -1 }); + expect(envelope.ok).toBe(false); + }); + + it('decodes an agent status snapshot with the embedded capability matrix', () => { + const snapshot = decodeAgentStatusSnapshot({ + schema_version: 1, + agent_instance_id: 'instance-1', + instance_state: 'ready', + session_id: 'session-1', + active_run_id: 'run-1', + active_run_state: 'running', + inbox_depth: 3, + activation_id: 'activation-1', + lease_expires_at: '2026-08-17T00:01:00Z', + capability: CAPABILITY_MATRIX, + }); + expect(snapshot.instance_state).toBe('ready'); + expect(snapshot.capability.pause.supported).toBe(false); + }); + + it('treats conflicting duplicate seq content as a protocol error', () => { + const cursor = createSessionEventCursor(); + cursor.accept(RUNTIME_EVENT); + expect(() => cursor.accept({ ...RUNTIME_EVENT, payload: { text: 'tampered' } })).toThrow( + SessionEventConflictError, + ); + }); +}); diff --git a/src/__tests__/api-client.test.ts b/src/__tests__/api-client.test.ts new file mode 100644 index 0000000..e65f2a4 --- /dev/null +++ b/src/__tests__/api-client.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ApiError, postJsonAction, streamAction } from '../api/client.js'; + +describe('AgentEngine action response parsing', () => { + it('explains Dashboard authentication when a hosted Agent returns an HTML 401', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('', { + status: 401, + headers: { 'content-type': 'text/html' }, + })); + const previousFetch = globalThis.fetch; + globalThis.fetch = fetchMock as typeof fetch; + + try { + await expect(postJsonAction('GetAgentUiBootstrap', { AgentId: 'agent-1' })) + .rejects + .toMatchObject({ + code: 401, + message: '访问会话已失效,请从 Dashboard 或 Studio 的云端会话重新打开 Agent。', + }); + } finally { + globalThis.fetch = previousFetch; + } + }); + + it('keeps malformed successful responses distinguishable from authentication failures', async () => { + const previousFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue(new Response('not-json', { + status: 200, + headers: { 'content-type': 'text/plain' }, + })) as typeof fetch; + + try { + await expect(postJsonAction('GetAgentUiBootstrap', { AgentId: 'agent-1' })) + .rejects + .toMatchObject({ code: -2, message: '响应格式异常' }); + } finally { + globalThis.fetch = previousFetch; + } + }); + + it('rejects an HTTP 200 action error before exposing it as an SSE stream', async () => { + const previousFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + Code: 503, + Message: 'runtime agent kernel is not ready', + Data: { + ReceiptStatus: 'rejected', + Error: { code: 'runtime_not_ready' }, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + })) as typeof fetch; + + try { + await expect(streamAction('RunAgent', { Stream: true })) + .rejects + .toMatchObject({ + code: 503, + message: 'runtime agent kernel is not ready', + }); + } finally { + globalThis.fetch = previousFetch; + } + }); +}); diff --git a/src/__tests__/api-facade.test.ts b/src/__tests__/api-facade.test.ts index e0fc838..abcdf11 100644 --- a/src/__tests__/api-facade.test.ts +++ b/src/__tests__/api-facade.test.ts @@ -294,3 +294,63 @@ describe('ApiFacadeImpl', () => { ]); }); }); + +describe('ApiFacadeImpl agent-kernel/v1 control surface', () => { + function stubFetch(calls: Array<{ url: string; body: Record }>) { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + calls.push({ + url: String(url), + body: JSON.parse(String(init?.body || '{}')) as Record, + }); + return new Response(JSON.stringify({ + Code: 0, + Data: { + schema_version: 1, + command_id: '4bf84e1b-f4cd-4c55-907f-2dc5e676b119', + status: 'accepted', + message_id: '11111111-2222-3333-4444-555555555555', + }, + }), { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + return () => { globalThis.fetch = originalFetch; }; + } + + it('submits agent control commands and returns a decoded receipt', async () => { + const facade = new ApiFacadeImpl(); + const calls: Array<{ url: string; body: Record }> = []; + const restore = stubFetch(calls); + try { + const receipt = await facade.submitControl({ + command_type: 'enqueue', + idempotency_key: 'idem-1', + payload: { content: 'hello' }, + }); + expect(receipt.status).toBe('accepted'); + expect(calls[0].url).toBe('/agentengine/api/v1/SubmitAgentControl'); + expect(calls[0].body.CommandType).toBe('enqueue'); + expect(calls[0].body.IdempotencyKey).toBe('idem-1'); + } finally { + restore(); + } + }); + + it('subscribes session events with the after_seq cursor', async () => { + const facade = new ApiFacadeImpl(); + const urls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url) => { + urls.push(String(url)); + return new Response(''); + }) as typeof fetch; + try { + await facade.subscribeSessionEvents('s1', 42); + } catch { + // transport errors are not the concern of this assertion + } finally { + globalThis.fetch = originalFetch; + } + // gateway 统一读取 after_seq(app/lifecycle.py resolve_after_seq),web 侧必须对齐。 + expect(urls[0]).toBe('/agentengine/api/v1/SubscribeSessionEvents?SessionId=s1&after_seq=42'); + }); +}); diff --git a/src/__tests__/bootstrap-auth.test.ts b/src/__tests__/bootstrap-auth.test.ts new file mode 100644 index 0000000..903e26c --- /dev/null +++ b/src/__tests__/bootstrap-auth.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiError, postJsonAction } from '../api/client.js'; +import { classifyBootstrapFailure } from '../hooks/useBootstrap.js'; + +describe('hosted UI bootstrap authorization', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('preserves a non-JSON HTTP authorization status', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('Unauthorized', { + status: 401, + statusText: 'Unauthorized', + }))); + + await expect(postJsonAction('GetAgentUiBootstrap', {})).rejects.toMatchObject({ + name: 'ApiError', + code: 401, + message: 'Unauthorized', + }); + }); + + it('fails closed for unauthorized bootstrap links', () => { + expect(classifyBootstrapFailure(new ApiError(401, 'Unauthorized'))).toBe('auth-required'); + expect(classifyBootstrapFailure(new ApiError(403, 'Forbidden'))).toBe('auth-required'); + expect(classifyBootstrapFailure(new ApiError(500, 'Unavailable'))).toBe('error'); + }); +}); diff --git a/src/__tests__/capabilities.test.ts b/src/__tests__/capabilities.test.ts index 093211f..7a9c548 100644 --- a/src/__tests__/capabilities.test.ts +++ b/src/__tests__/capabilities.test.ts @@ -37,4 +37,67 @@ describe('normalizeCapabilities', () => { expect(capabilities.RunLifecycle.CheckpointResume).toBe(true); expect(capabilities.RunLifecycle.CheckpointResumePreview).toBe(true); }); + + it('projects an explicit RuntimeCapabilityMatrix and fails closed for legacy or malformed bootstraps', () => { + const native = { supported: true, mode: 'native' }; + const base = { + schema_version: 1, + cancel: native, + pause: native, + resume: native, + submit_interaction: native, + attach: native, + steer: native, + inject: native, + checkpoint: native, + durable_restore: native, + }; + + const advertised = normalizeCapabilities({ + Data: { + Capabilities: { + RuntimeCapabilityMatrix: { + ...base, + goal: native, + loop: native, + plan: native, + }, + }, + }, + }); + expect(advertised.RuntimeCapabilityMatrix?.goal?.supported).toBe(true); + expect(advertised.RuntimeCapabilityMatrix?.loop?.supported).toBe(true); + expect(advertised.RuntimeCapabilityMatrix?.plan?.supported).toBe(true); + + expect(normalizeCapabilities({ Data: { Capabilities: {} } }).RuntimeCapabilityMatrix).toBeUndefined(); + expect(normalizeCapabilities({ + Data: { Capabilities: { RuntimeCapabilityMatrix: { schema_version: 1 } } }, + }).RuntimeCapabilityMatrix).toBeUndefined(); + }); +}); + +import { decodeCapabilityMatrix } from '../types/agent-control.js'; + +describe('agent-kernel/v1 capability matrix', () => { + it('decodes the canonical matrix and keeps unsupported reasons addressable', () => { + const matrix = decodeCapabilityMatrix({ + schema_version: 1, + cancel: { supported: true, mode: 'native' }, + pause: { supported: false, mode: 'unavailable', reason: 'runtime_pause_not_supported' }, + resume: { supported: true, mode: 'native' }, + submit_interaction: { supported: true, mode: 'native' }, + attach: { supported: true, mode: 'native' }, + steer: { supported: true, mode: 'native' }, + inject: { supported: true, mode: 'native' }, + checkpoint: { supported: false, mode: 'unavailable', reason: 'runtime_checkpoint_not_supported' }, + durable_restore: { supported: true, mode: 'native' }, + }); + expect(matrix.pause.supported).toBe(false); + expect(matrix.pause.reason).toBe('runtime_pause_not_supported'); + expect(matrix.checkpoint.mode).toBe('unavailable'); + }); + + it('rejects matrices that violate the contract', () => { + expect(() => decodeCapabilityMatrix({ schema_version: 1 })).toThrow(); + }); }); diff --git a/src/__tests__/chat-message-list-contract.test.ts b/src/__tests__/chat-message-list-contract.test.ts index 2015a22..72d897c 100644 --- a/src/__tests__/chat-message-list-contract.test.ts +++ b/src/__tests__/chat-message-list-contract.test.ts @@ -71,6 +71,31 @@ describe('chat message list contracts', () => { expect(source).toContain('onLoadMoreSessions()'); }); + it('keeps each session selection control separate from pin and delete actions', () => { + const source = readFileSync(resolve(repoRoot, 'src/components/chat/ChatSidebar.tsx'), 'utf8'); + + // A role=button wrapper containing real action buttons creates an invalid + // interactive nesting tree. Browsers then route the delete click to the + // session selector instead of the delete handler. + expect(source).not.toContain('role="button"'); + expect(source).toContain('onClick={() => onSelectSession(session.SessionId)}'); + expect(source).toContain('onDeleteSession(session.SessionId, event)'); + expect(source).toContain('group-focus-within:opacity-100'); + }); + + it('keeps hosted session activity as compact visual state instead of status copy', () => { + const source = readFileSync(resolve(repoRoot, 'src/components/chat/ChatSidebar.tsx'), 'utf8'); + + expect(source).toContain('aria-label="\u8fd0\u884c\u4e2d"'); + expect(source).toContain('aria-label="\u8fd0\u884c\u5931\u8d25"'); + expect(source).toContain('h-1.5 w-1.5'); + expect(source).toContain('border-t-transparent'); + expect(source).toContain('bg-primary/[0.035]'); + expect(source).not.toContain('bg-primary/8'); + expect(source).not.toContain('\u6b63\u5728\u8fd0\u884c'); + expect(source).not.toContain('completed'); + }); + it('keeps reasoning panels scrollable and lightweight', () => { const source = readFileSync(resolve(repoRoot, 'src/components/chat/ChatMessageList.tsx'), 'utf8'); @@ -145,7 +170,18 @@ describe('chat message list contracts', () => { expect(lifecycleSource).toContain('loadOlderSessionMessages'); expect(lifecycleSource).toContain('beforeSeqId: historyState.nextCursor'); expect(lifecycleSource).toContain('SESSION_MESSAGES_PAGE_SIZE'); - expect(lifecycleSource).not.toContain('api.listSessionEvents(sessionId'); + // Raw event history must not be loaded for the transcript. The only + // allowed listSessionEvents call is the Interaction/v1 pending + // replay (0.3.2), which ingests durable interaction facts only and + // never issues a submit. + const listSessionEventsCalls = lifecycleSource + .split('\n') + .filter((line) => line.includes('api.listSessionEvents(sessionId')); + expect(listSessionEventsCalls).toHaveLength(1); + expect(listSessionEventsCalls[0]).toContain('limit: 50'); + expect( + lifecycleSource.indexOf('api.listSessionEvents(sessionId'), + ).toBeGreaterThan(lifecycleSource.indexOf('Interaction/v1')); expect(lifecycleSource).toContain("console.warn('[SessionLifecycle] checkpoint load failed:'"); expect(lifecycleSource).toContain("console.warn('[SessionLifecycle] tool receipt load failed:'"); }); diff --git a/src/__tests__/composer-contract.test.ts b/src/__tests__/composer-contract.test.ts index 933e124..cd03173 100644 --- a/src/__tests__/composer-contract.test.ts +++ b/src/__tests__/composer-contract.test.ts @@ -26,7 +26,53 @@ describe('ChatComposer interaction contract', () => { expect(source).not.toContain('multimodal_input_image === false'); expect(source).not.toContain('不支持图片输入'); - expect(source).toContain('void submitDraft(draftText, draftAttachments)'); + expect(source).toContain('void submitDraft(draftText, draftAttachments,'); + }); + + it('uses the compact Codex-style composer controls without native selects', () => { + const composerSource = readSource('components/chat/ChatComposer.tsx'); + const executionMenuSource = readSource('components/chat/ExecutionModeMenu.tsx'); + const modelMenuSource = readSource('components/chat/ModelSettingsMenu.tsx'); + const permissionSource = readSource('components/chat/PermissionMenu.tsx'); + + expect(composerSource).toContain(' { + const appSource = readSource('App.tsx'); + const connectedSource = readSource('components/chat/ConnectedComposer.tsx'); + const runSource = readSource('hooks/useRunAgent.ts'); + + expect(appSource).toContain('attachmentsEnabled={uiCapabilities.Attachments !== false}'); + expect(appSource).toContain('thinkingEnabled={thinkingEnabled}'); + expect(connectedSource).toContain("if (!thinkingEnabled) setThinkingMode('auto')"); + expect(runSource).toContain("thinkingMode: uiCapabilities.Thinking ? thinkingMode : 'auto'"); + }); + + it('shows goal and plan only from explicit typed runtime capabilities', () => { + const appSource = readSource('App.tsx'); + const composerSource = readSource('components/chat/ChatComposer.tsx'); + const actionMenuSource = readSource('components/chat/ExecutionModeMenu.tsx'); + const runSource = readSource('core/run/engine.ts'); + + expect(appSource).toContain('runtimeCapabilityMatrix={uiCapabilities.RuntimeCapabilityMatrix}'); + expect(composerSource).toContain(' { diff --git a/src/__tests__/interaction-client.test.ts b/src/__tests__/interaction-client.test.ts new file mode 100644 index 0000000..338b908 --- /dev/null +++ b/src/__tests__/interaction-client.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it, vi } from 'vitest'; +import { InteractionClientImpl } from '../core/interaction/client.js'; +import { InteractionStore } from '../core/interaction/store.js'; +import { interactionFromSessionEvent } from '../core/interaction/adapters/session-events.js'; +import { interactionFromResponsesApproval } from '../core/interaction/adapters/responses.js'; +import { interactionFromAguiInterrupt } from '../core/interaction/adapters/agui.js'; +import { interactionIdempotencyKey } from '../core/interaction/types.js'; + +function okReceipt(overrides: Record = {}) { + return { + schema_version: 1, + command_id: 'cmd-1', + status: 'accepted', + ...overrides, + }; +} + +/** Equivalent pending approval expressed by each transport. */ +const FIXTURES = { + interactionV1: { + schema_version: 1, + event_id: 'evt-1', + session_id: 'session-1', + seq: 1, + timestamp: '2026-08-19T00:00:00Z', + family: 'control', + family_version: 1, + event_type: 'interaction.requested', + run_id: 'run-1', + payload: { + interaction: { + interaction_id: 'int-1', + session_id: 'session-1', + run_id: 'run-1', + kind: 'approval', + revision: 1, + created_at: '2026-08-19T00:00:00Z', + request_schema: { type: 'object' }, + }, + }, + }, + responses: null as unknown as ReturnType, + agui: null as unknown as ReturnType, +}; + +FIXTURES.responses = interactionFromResponsesApproval({ + approvalRequestId: 'int-1', + sessionId: 'session-1', + runId: 'run-1', +}); + +FIXTURES.agui = interactionFromAguiInterrupt({ + interruptId: 'int-1', + sessionId: 'session-1', + runId: 'run-1', +}); + +describe('three-source normalization', () => { + it('normalizes Responses, AG-UI, and Interaction/v1 requests to the same Interaction shape', () => { + const v1 = interactionFromSessionEvent(FIXTURES.interactionV1); + expect(v1).not.toBeNull(); + expect(v1!.interactionId).toBe('int-1'); + expect(v1!.sessionId).toBe('session-1'); + expect(v1!.status).toBe('pending'); + expect(v1!.kind).toBe('approval'); + expect(v1!.revision).toBe(1); + expect(v1!.source).toBe('interaction_v1'); + + for (const interaction of [FIXTURES.responses, FIXTURES.agui]) { + expect(interaction).not.toBeNull(); + expect(interaction!.interactionId).toBe('int-1'); + expect(interaction!.sessionId).toBe('session-1'); + expect(interaction!.status).toBe('pending'); + expect(interaction!.kind).toBe('approval'); + expect(interaction!.revision).toBe(1); + } + }); + + it('resolves Interaction/v1 terminal events with outcome, not a fifth event type', () => { + const resolved = interactionFromSessionEvent({ + ...FIXTURES.interactionV1, + event_type: 'interaction.resolved', + payload: { + interaction_id: 'int-1', + outcome: 'rejected', + resolved_at: '2026-08-19T00:01:00Z', + actor: 'user-2', + }, + }); + expect(resolved).not.toBeNull(); + expect(resolved!.status).toBe('resolved'); + expect(resolved!.outcome).toBe('rejected'); + expect(resolved!.actor).toBe('user-2'); + }); +}); + +describe('single submit path', () => { + it('routes every Interaction/v1 action through SubmitInteraction exactly once', async () => { + const submitInteraction = vi.fn().mockResolvedValue(okReceipt()); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + client.ingest(interactionFromSessionEvent(FIXTURES.interactionV1)!); + + const receipt = await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + + expect(receipt.status).toBe('accepted'); + expect(submitInteraction).toHaveBeenCalledTimes(1); + expect(submitInteraction).toHaveBeenCalledWith({ + AgentId: 'agent-1', + SessionId: 'session-1', + RunId: 'run-1', + InteractionId: 'int-1', + ExpectedRevision: 1, + Action: 'approve', + Response: { approved: true }, + IdempotencyKey: 'interaction:int-1:revision-1', + }); + expect(client.listPending('session-1')).toHaveLength(0); + }); + + it('keeps the official mcp_approval_response path for pure /v1/responses clients', async () => { + const submitInteraction = vi.fn(); + const legacyResponsesApproval = vi.fn(); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + legacyResponsesApproval, + }); + client.ingest(FIXTURES.responses!); + + const receipt = await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + + expect(receipt.status).toBe('accepted'); + expect(legacyResponsesApproval).toHaveBeenCalledWith('int-1', true); + expect(submitInteraction).not.toHaveBeenCalled(); + }); + + it('routes AG-UI records through resumeAguiInterrupt without SubmitInteraction', async () => { + const submitInteraction = vi.fn(); + const legacyAguiResume = vi.fn().mockReturnValue(true); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + legacyAguiResume, + }); + client.ingest(FIXTURES.agui!); + + await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'reject', + response: { approved: false }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + + expect(legacyAguiResume).toHaveBeenCalledWith('int-1', 'resolved', { approved: false }); + expect(submitInteraction).not.toHaveBeenCalled(); + }); + + it('never sends a second request for a double click', async () => { + const submitInteraction = vi.fn( + () => new Promise((resolve) => setTimeout(() => resolve(okReceipt()), 20)), + ); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + client.ingest(interactionFromSessionEvent(FIXTURES.interactionV1)!); + + const [first, second] = await Promise.all([ + client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }), + client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }), + ]); + + expect(submitInteraction).toHaveBeenCalledTimes(1); + expect(first.status).toBe('accepted'); + expect(second.status).toBe('duplicate'); + }); + + it('treats a first-wins rejection from another tab as a failed submit with the server error', async () => { + const submitInteraction = vi.fn().mockResolvedValue( + okReceipt({ + status: 'rejected', + error: { + code: 'interaction_already_resolved', + message: 'first-wins', + retryable: false, + }, + }), + ); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + client.ingest(interactionFromSessionEvent(FIXTURES.interactionV1)!); + + const receipt = await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + + expect(receipt.status).toBe('rejected'); + const record = client.store.get('session-1', 'int-1')!; + expect(record.status).toBe('failed'); + const error = record.extensions.submit_error as { code: string; message: string }; + expect(error.code).toBe('interaction_already_resolved'); + expect(error.message).toBe('first-wins'); + }); +}); + +describe('receipts are not terminal facts', () => { + it('keeps the record resolving after an accepted receipt; only the SessionEvent resolves it', async () => { + const submitInteraction = vi.fn().mockResolvedValue(okReceipt()); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + client.ingest(interactionFromSessionEvent(FIXTURES.interactionV1)!); + + const receipt = await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + + expect(receipt.status).toBe('accepted'); + // Receipt proves the command entered the durable Inbox — not a + // terminal state. The UI keeps showing "resolving". + expect(client.store.get('session-1', 'int-1')!.status).toBe('resolving'); + + // The authoritative terminal fact is the SessionEvent. + client.ingest( + interactionFromSessionEvent({ + ...FIXTURES.interactionV1, + event_type: 'interaction.resolved', + payload: { + interaction_id: 'int-1', + outcome: 'approved', + actor: 'user', + resolved_at: '2026-08-19T00:01:00Z', + }, + })!, + ); + const resolved = client.store.get('session-1', 'int-1')!; + expect(resolved.status).toBe('resolved'); + expect(resolved.outcome).toBe('approved'); + }); + + it('never resolves to cancelled off a duplicate receipt either', async () => { + const submitInteraction = vi.fn().mockResolvedValue( + okReceipt({ status: 'duplicate' }), + ); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + client.ingest(interactionFromSessionEvent(FIXTURES.interactionV1)!); + + await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'cancel', + response: {}, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + + expect(client.store.get('session-1', 'int-1')!.status).toBe('resolving'); + }); + + it('a second respond while the record is resolving is a local duplicate', async () => { + const submitInteraction = vi.fn().mockResolvedValue(okReceipt()); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + client.ingest(interactionFromSessionEvent(FIXTURES.interactionV1)!); + await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + expect(submitInteraction).toHaveBeenCalledTimes(1); + + const second = await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + expect(second.status).toBe('duplicate'); + expect(submitInteraction).toHaveBeenCalledTimes(1); + expect(client.store.get('session-1', 'int-1')!.status).toBe('resolving'); + }); + + it('reverts a retryable queue_full receipt to pending', async () => { + const submitInteraction = vi.fn().mockResolvedValue( + okReceipt({ + status: 'queue_full', + error: { + code: 'queue_full', + message: 'inbox queue is full', + retryable: true, + }, + }), + ); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + client.ingest(interactionFromSessionEvent(FIXTURES.interactionV1)!); + + const receipt = await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + expect(receipt.status).toBe('queue_full'); + expect(client.store.get('session-1', 'int-1')!.status).toBe('pending'); + }); +}); + +describe('store first-wins semantics', () => { + it('never regresses a terminal record to pending', () => { + const store = new InteractionStore(); + const pending = interactionFromSessionEvent(FIXTURES.interactionV1)!; + store.upsert(pending); + store.upsert({ + ...pending, + status: 'resolved', + outcome: 'approved', + actor: 'user-1', + resolvedAt: '2026-08-19T00:01:00Z', + }); + // A late/replayed requested event must not resurrect the decision. + const event = store.upsert(pending); + const record = store.get('session-1', 'int-1')!; + expect(record.status).toBe('resolved'); + expect(record.outcome).toBe('approved'); + expect(event.type).toBe('interaction_updated'); + }); + + it('expires pending records via the expired terminal event', () => { + const store = new InteractionStore(); + store.upsert(interactionFromSessionEvent(FIXTURES.interactionV1)!); + store.upsert( + interactionFromSessionEvent({ + ...FIXTURES.interactionV1, + event_type: 'interaction.expired', + payload: { interaction_id: 'int-1' }, + })!, + ); + expect(store.get('session-1', 'int-1')!.status).toBe('expired'); + expect(store.get('session-1', 'int-1')!.outcome).toBe('expired'); + }); +}); diff --git a/src/__tests__/interaction-replay.test.ts b/src/__tests__/interaction-replay.test.ts new file mode 100644 index 0000000..bc43759 --- /dev/null +++ b/src/__tests__/interaction-replay.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi } from 'vitest'; +import { InteractionClientImpl } from '../core/interaction/client.js'; +import { interactionFromSessionEvent } from '../core/interaction/adapters/session-events.js'; +import { interactionFromResponsesApproval } from '../core/interaction/adapters/responses.js'; +import { interactionFromAguiInterrupt } from '../core/interaction/adapters/agui.js'; +import { interactionIdempotencyKey } from '../core/interaction/types.js'; + +const REQUESTED_EVENT = { + schema_version: 1, + event_id: 'evt-1', + session_id: 'session-1', + seq: 1, + timestamp: '2026-08-19T00:00:00Z', + family: 'control', + family_version: 1, + event_type: 'interaction.requested', + run_id: 'run-1', + payload: { + interaction: { + interaction_id: 'int-1', + session_id: 'session-1', + run_id: 'run-1', + kind: 'approval', + revision: 1, + created_at: '2026-08-19T00:00:00Z', + }, + }, +}; + +function okReceipt() { + return { schema_version: 1, command_id: 'cmd-1', status: 'accepted' }; +} + +describe('refresh and replay', () => { + it('restores a pending interaction from replayed history without a second request', async () => { + const submitInteraction = vi.fn(); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + + client.ingestHistory([ + interactionFromSessionEvent(REQUESTED_EVENT)!, + ]); + + expect(client.listPending('session-1')).toHaveLength(1); + expect(client.listPending('session-1')[0].interactionId).toBe('int-1'); + expect(submitInteraction).not.toHaveBeenCalled(); + }); + + it('restores pending interactions from all three transports on refresh', () => { + const submitInteraction = vi.fn(); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + + client.ingestHistory([ + interactionFromSessionEvent(REQUESTED_EVENT)!, + interactionFromResponsesApproval({ + approvalRequestId: 'int-2', + sessionId: 'session-1', + runId: 'run-1', + })!, + interactionFromAguiInterrupt({ + interruptId: 'int-3', + sessionId: 'session-1', + runId: 'run-1', + })!, + ]); + + expect(client.listPending('session-1').map((i) => i.interactionId)).toEqual([ + 'int-1', + 'int-2', + 'int-3', + ]); + expect(submitInteraction).not.toHaveBeenCalled(); + }); + + it('replay after a completed submit does not create a second request', async () => { + const submitInteraction = vi.fn().mockResolvedValue(okReceipt()); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + client.ingest(interactionFromSessionEvent(REQUESTED_EVENT)!); + await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + expect(submitInteraction).toHaveBeenCalledTimes(1); + + // Refresh replays the full history including the requested event. + client.ingestHistory([interactionFromSessionEvent(REQUESTED_EVENT)!]); + // And the server-side resolved fact: + client.ingestHistory([ + interactionFromSessionEvent({ + ...REQUESTED_EVENT, + event_type: 'interaction.resolved', + payload: { + interaction_id: 'int-1', + outcome: 'approved', + actor: 'user-1', + resolved_at: '2026-08-19T00:01:00Z', + }, + })!, + ]); + + expect(client.listPending('session-1')).toHaveLength(0); + expect(submitInteraction).toHaveBeenCalledTimes(1); + }); + + it('a second respond after replay of the terminal fact is a local duplicate', async () => { + const submitInteraction = vi.fn().mockResolvedValue(okReceipt()); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + client.ingest(interactionFromSessionEvent(REQUESTED_EVENT)!); + await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + // Still resolving: a receipt is not a terminal fact. + expect(client.store.get('session-1', 'int-1')!.status).toBe('resolving'); + + // Replay of the authoritative terminal fact converges the record. + client.ingestHistory([ + interactionFromSessionEvent({ + ...REQUESTED_EVENT, + event_type: 'interaction.resolved', + payload: { + interaction_id: 'int-1', + outcome: 'approved', + actor: 'user-1', + resolved_at: '2026-08-19T00:01:00Z', + }, + })!, + ]); + + const second = await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'reject', + response: { approved: false }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + + expect(second.status).toBe('duplicate'); + expect(submitInteraction).toHaveBeenCalledTimes(1); + // First-wins outcome is preserved. + const record = client.listPending('session-1'); + expect(record).toHaveLength(0); + }); + + it('a resolving record converges to resolved when replay contains the terminal event', async () => { + const submitInteraction = vi.fn().mockResolvedValue(okReceipt()); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + client.ingest(interactionFromSessionEvent(REQUESTED_EVENT)!); + await client.respond({ + interactionId: 'int-1', + expectedRevision: 1, + action: 'approve', + response: { approved: true }, + idempotencyKey: interactionIdempotencyKey('int-1', 1), + }); + expect(client.store.get('session-1', 'int-1')!.status).toBe('resolving'); + + // Disconnect/replay: the event stream already carries the terminal + // fact — the resolving record must converge immediately. + client.ingestHistory([ + interactionFromSessionEvent(REQUESTED_EVENT)!, + interactionFromSessionEvent({ + ...REQUESTED_EVENT, + event_type: 'interaction.resolved', + payload: { + interaction_id: 'int-1', + outcome: 'approved', + actor: 'user-1', + resolved_at: '2026-08-19T00:01:00Z', + }, + })!, + ]); + expect(client.store.get('session-1', 'int-1')!.status).toBe('resolved'); + expect(client.listPending('session-1')).toHaveLength(0); + expect(submitInteraction).toHaveBeenCalledTimes(1); + }); + + it('a resolved record keeps the requested snapshot fields (title, kind, request_schema)', () => { + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction: vi.fn(), + }); + client.ingestHistory([ + interactionFromSessionEvent({ + ...REQUESTED_EVENT, + payload: { + interaction: { + ...REQUESTED_EVENT.payload.interaction, + title: '部署确认', + message: '请选择部署目标', + request_schema: { + type: 'object', + properties: { deploy_target: { type: 'string' } }, + }, + }, + }, + })!, + ]); + const resolved = interactionFromSessionEvent({ + ...REQUESTED_EVENT, + event_type: 'interaction.resolved', + event_id: 'evt-2', + payload: { + interaction_id: 'int-1', + outcome: 'submitted', + actor: 'user', + resolved_at: '2026-08-19T00:01:00Z', + }, + }); + + const seen: unknown[] = []; + client.subscribe((event) => { + if (event.type === 'interaction_resolved') seen.push(event.interaction); + }); + client.ingestHistory([resolved!]); + const record = seen[0] as (typeof seen)[0] & { + title: string; + requestSchema: Record | null; + }; + expect(record).toBeDefined(); + expect(record.status).toBe('resolved'); + expect(record.title).toBe('部署确认'); + expect(record.requestSchema).toEqual({ + type: 'object', + properties: { deploy_target: { type: 'string' } }, + }); + }); + + it('expires pending interactions on replay of expiry events', () => { + const submitInteraction = vi.fn(); + const client = new InteractionClientImpl({ + agentId: 'agent-1', + submitInteraction, + }); + client.ingestHistory([interactionFromSessionEvent(REQUESTED_EVENT)!]); + client.ingestHistory([ + interactionFromSessionEvent({ + ...REQUESTED_EVENT, + event_type: 'interaction.expired', + payload: { interaction_id: 'int-1' }, + })!, + ]); + expect(client.listPending('session-1')).toHaveLength(0); + }); +}); diff --git a/src/__tests__/interaction-tray.test.ts b/src/__tests__/interaction-tray.test.ts new file mode 100644 index 0000000..f48e402 --- /dev/null +++ b/src/__tests__/interaction-tray.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + A2UI_WIRE_VERSION, + computeA2uiCatalogDigest, + validateA2uiPresentation, +} from '../core/interaction/a2ui-validate.js'; + +function readSource(relativePath: string): string { + return readFileSync( + fileURLToPath(new URL(`../${relativePath}`, import.meta.url)), + 'utf-8', + ); +} + +describe('InteractionTray contract', () => { + it('renders above the composer inside ConnectedComposer', () => { + const source = readSource('components/chat/ConnectedComposer.tsx'); + expect(source).toContain('InteractionTray'); + expect(source.indexOf('InteractionTray')).toBeLessThan( + source.indexOf(' { + const source = readSource('components/chat/InteractionTray.tsx'); + expect(source).toContain("respond('approve'"); + expect(source).toContain("respond('reject'"); + expect(source).toContain('disabled={disabled}'); + // No approval on mount or expiry. + expect(source).not.toContain('useEffect'); + }); + + it('uses the shared Interaction submit path, not protocol branches', () => { + const source = readSource('components/chat/InteractionTray.tsx'); + expect(source).not.toContain('approvalProtocol'); + expect(source).not.toContain('mcp_approval_response'); + expect(source).not.toContain('resumeAguiInterrupt'); + }); + + it('shows pending count and supports queue navigation', () => { + const source = readSource('components/chat/InteractionTray.tsx'); + expect(source).toContain('data-testid="interaction-tray-count"'); + expect(source).toContain('data-testid="interaction-tray-prev"'); + expect(source).toContain('data-testid="interaction-tray-next"'); + }); + + it('resolved anchors expand into a read-only snapshot with schema summary', () => { + const source = readSource('components/chat/InteractionHistoryAnchor.tsx'); + expect(source).toContain("interaction.actor"); + expect(source).toContain('interaction-history-detail'); + expect(source).toContain('requestSchemaKeys'); + // The snapshot is never editable: no inputs, no form, no buttons. + expect(source).not.toContain(' { + const source = readSource('components/chat/InteractionHistoryAnchor.tsx'); + expect(source).not.toContain(' { + const source = readSource('components/chat/ChatMessageList.tsx'); + expect(source).toContain('InteractionHistoryAnchor'); + // Anchors render after the (collapsed) tool details and filter on + // records whose id matches a tool approvalRequestId. + const anchorIndex = source.indexOf('interactionRecords?.some'); + expect(anchorIndex).toBeGreaterThan(-1); + // The interactive buttons only render for tools without an + // Interaction record and only while pending. + expect(source).toContain("tool.approvalStatus === 'pending' ? ("); + }); + + it('keeps a streamed tool approval read-only once the composer tray owns it', () => { + const source = readSource('components/chat/ProcessingBlocksView.tsx'); + expect(source).toContain('interactionRecord={record}'); + expect(source).toContain("approvalStatus === 'pending' && !interactionRecord"); + expect(source).toContain('请在输入区确认面板中操作。'); + }); +}); + +describe('A2UI v0.9.1 validation and safe fallback', () => { + const catalog = { components: [{ id: 'root' }, { id: 'text' }] }; + + it('accepts the locked wire version with a matching catalog digest', () => { + const matching = { + wireVersion: A2UI_WIRE_VERSION, + catalogDigest: computeA2uiCatalogDigest(catalog), + messages: [{ id: 'm1' }], + }; + expect(validateA2uiPresentation(matching, catalog)).toBe('a2ui'); + }); + + it('rejects unknown wire versions without ever mapping to approval', () => { + const future = { + wireVersion: '1.0', + catalogDigest: 'whatever', + messages: [{ id: 'm1' }], + }; + expect(validateA2uiPresentation(future, catalog)).not.toBe('a2ui'); + }); + + it('falls back to the JSON schema form on catalog mismatch', () => { + const mismatch = { + wireVersion: A2UI_WIRE_VERSION, + catalogDigest: 'different-digest', + messages: [{ inputSchema: { type: 'object' } }], + }; + expect(validateA2uiPresentation(mismatch, catalog)).toBe('json-schema-form'); + }); + + it('falls back to basic controls when no schema is usable', () => { + expect(validateA2uiPresentation(null, catalog)).toBe('basic-controls'); + expect( + validateA2uiPresentation( + { wireVersion: A2UI_WIRE_VERSION, catalogDigest: '', messages: [] }, + catalog, + ), + ).toBe('basic-controls'); + }); +}); diff --git a/src/__tests__/kernel-events.test.ts b/src/__tests__/kernel-events.test.ts new file mode 100644 index 0000000..e30be3a --- /dev/null +++ b/src/__tests__/kernel-events.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; + +import { KernelRunEventTranslator, peekKernelReceipt } from '../core/stream/kernel-events.js'; + +function itemFrame( + seq: number, + eventType: string, + nativeKind: string, + extra: Record = {}, +) { + return { + seq, + family: 'runtime', + family_version: 2, + event_type: eventType, + run_id: 'run-1', + item_id: `item-${seq}`, + event_id: `event-${seq}`, + item_kind: nativeKind === 'reasoning' ? 'reasoning' : 'message', + source: { metadata: { native_item_kind: nativeKind } }, + ...extra, + }; +} + +const USER_SNAPSHOT = { + snapshot: { + parts: [ + { + part_id: 'p1', + content_type: 'data', + data: { type: 'userMessage', content: [{ type: 'text', text: '你好' }] }, + }, + ], + }, +}; + +const AGENT_SNAPSHOT = (text: string) => ({ + snapshot: { parts: [{ part_id: 'p2', content_type: 'text', text }] }, +}); + +describe('KernelRunEventTranslator', () => { + it('projects userMessage completion to a user_message record', () => { + const t = new KernelRunEventTranslator('sess-1'); + const record = t.translate(itemFrame(9, 'item.completed', 'userMessage', USER_SNAPSHOT)); + expect(record?.EventType).toBe('user_message'); + expect(record?.Content).toEqual({ parts: [{ text: '你好' }] }); + expect(record?.SeqId).toBe(9); + }); + + it('projects agentMessage completion to an assistant_message record', () => { + const t = new KernelRunEventTranslator('sess-1'); + const record = t.translate( + itemFrame(10, 'item.completed', 'agentMessage', AGENT_SNAPSHOT('回复内容')), + ); + expect(record?.EventType).toBe('assistant_message'); + expect(record?.Content).toEqual({ parts: [{ text: '回复内容' }] }); + }); + + it('accumulates item.updated append deltas into cumulative snapshots', () => { + const t = new KernelRunEventTranslator('sess-1'); + const first = t.translate( + itemFrame(11, 'item.updated', 'agentMessage', { op: 'replace', ...AGENT_SNAPSHOT('你好') }), + ); + const second = t.translate( + itemFrame(12, 'item.updated', 'agentMessage', { op: 'append', ...AGENT_SNAPSHOT(',世界') }), + ); + expect(first?.Content).toEqual({ parts: [{ text: '你好' }] }); + expect(second?.Content).toEqual({ parts: [{ text: '你好,世界' }] }); + }); + + it('translates interaction frames verbatim for the interaction adapter', () => { + const t = new KernelRunEventTranslator('sess-1'); + const record = t.translate({ + seq: 6, + family: 'interaction', + family_version: 1, + event_type: 'interaction.requested', + interaction_id: 'item_abc', + kind: 'approval', + run_id: 'run-1', + }); + expect(record?.EventType).toBe('interaction.requested'); + expect((record?.payload as Record)?.interaction_id).toBe('item_abc'); + }); + + it('maps terminal control run transitions to run_status', () => { + const t = new KernelRunEventTranslator('sess-1'); + const record = t.translate({ + seq: 22, + family: 'control', + event_type: 'control.run_transition', + state: 'completed', + run_id: 'run-1', + }); + expect(record?.EventType).toBe('run_status'); + expect(record?.Content).toEqual({ status: 'completed' }); + }); + + it('maps tool items to tool_call / tool_result with call_id pairing', () => { + const t = new KernelRunEventTranslator('sess-1'); + const started = t.translate( + itemFrame(13, 'item.started', 'commandExecution', { + item_id: 'tool-1', + initial: { parts: [{ part_id: 'p', content_type: 'data', data: { command: 'ls' } }] }, + }), + ); + const completed = t.translate( + itemFrame(14, 'item.completed', 'commandExecution', { + item_id: 'tool-1', + snapshot: { parts: [{ part_id: 'p', content_type: 'data', data: { exitCode: 0 } }] }, + }), + ); + expect(started?.EventType).toBe('tool_call'); + expect(started?.Metadata?.call_id).toBe('tool-1'); + expect(completed?.EventType).toBe('tool_result'); + expect(completed?.Metadata?.call_id).toBe('tool-1'); + }); + + it('skips control noise and non-runtime families', () => { + const t = new KernelRunEventTranslator('sess-1'); + expect(t.translate({ seq: 1, family: 'control', event_type: 'control.command_accepted' })).toBeNull(); + expect(t.translate({ seq: 2 })).toBeNull(); + }); +}); + +describe('peekKernelReceipt', () => { + function streamOf(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }); + } + + it('detects the kernel receipt JSON body', async () => { + const body = JSON.stringify({ + Code: 0, + Message: 'Success', + RequestId: 'r', + Action: 'RunAgent', + Data: { ReceiptStatus: 'accepted', MessageId: 'm1', RunId: null, AcceptedSeq: 5 }, + }); + const { receipt, stream } = await peekKernelReceipt(streamOf([body])); + expect(receipt?.status).toBe('accepted'); + expect(receipt?.acceptedSeq).toBe(5); + const text = await new Response(stream).text(); + expect(JSON.parse(text).Data.ReceiptStatus).toBe('accepted'); + }); + + it('replays SSE bodies untouched', async () => { + const sse = 'id: 1\ndata: {"foo":1}\n\n'; + const { receipt, stream } = await peekKernelReceipt(streamOf([sse])); + expect(receipt).toBeNull(); + const text = await new Response(stream).text(); + expect(text).toBe(sse); + }); + + it('carries rejection errors through the receipt', async () => { + const body = JSON.stringify({ + Code: 503, + Data: { ReceiptStatus: 'rejected', Error: { code: 'runtime_not_ready', message: 'nope' } }, + }); + const { receipt } = await peekKernelReceipt(streamOf([body])); + expect(receipt?.status).toBe('rejected'); + expect(receipt?.error?.message).toBe('nope'); + }); +}); diff --git a/src/__tests__/run-engine.test.ts b/src/__tests__/run-engine.test.ts index f7e5aae..962ba35 100644 --- a/src/__tests__/run-engine.test.ts +++ b/src/__tests__/run-engine.test.ts @@ -6,12 +6,13 @@ import { useMessageStore } from '../stores/message.js'; import { useSessionStore } from '../stores/session.js'; import { useCheckpointStore } from '../stores/checkpoint.js'; import { dispatchRunEventToStores, resetDispatcherState } from '../core/run/dispatcher.js'; +import { sharedInteractionStore } from '../core/interaction/index.js'; function createApiFacade(calls: Record[], uploadCalls: FormData[] = []): ApiFacade { return { async listSessions() { return []; }, async createSession() { return { SessionId: 'session-1' }; }, - async deleteSession() {}, + async deleteSession() { return {}; }, async getSession() { return { SessionId: 'session-1', ActiveRunStatus: '' }; }, @@ -181,7 +182,7 @@ describe('RunEngineImpl', () => { }); }); - it('keeps a Responses approval inside its tool row instead of appending an interruption notice', async () => { + it('keeps a Responses approval in tool history and mirrors it into the composer interaction queue', async () => { const calls: Record[] = []; const engine = createRunEngine({ ...createApiFacade(calls), @@ -219,6 +220,13 @@ describe('RunEngineImpl', () => { approvalStatus: 'pending', status: 'paused', }); + expect(sharedInteractionStore.get('session-inline-approval', 'approval-1')).toMatchObject({ + source: 'responses', + interactionId: 'approval-1', + sessionId: 'session-inline-approval', + status: 'pending', + title: '审批:web_search', + }); }); it('projects a live AG-UI approval into a fresh assistant row after a session switch', () => { @@ -478,6 +486,85 @@ describe('RunEngineImpl', () => { }); }); + it('maps advertised plan and goal controls to distinct runtime request metadata', async () => { + const native = { supported: true, mode: 'native' as const, extensions: {} }; + const runtimeCapabilityMatrix = { + schema_version: 1 as const, + cancel: native, + pause: native, + resume: native, + submit_interaction: native, + attach: native, + steer: native, + inject: native, + checkpoint: native, + durable_restore: native, + goal: native, + loop: native, + plan: native, + extensions: {}, + }; + const cases = [ + { + mode: 'plan' as const, + expected: { collaboration_mode: 'plan' }, + }, + { + mode: 'goal' as const, + expected: { collaboration_mode: 'default', goal_objective: 'ship the goal' }, + }, + ]; + + for (const testCase of cases) { + const calls: Record[] = []; + const engine = createRunEngine(createApiFacade(calls)); + engine.updateConfig({ + agentId: 'agent-live', + apiFormats: ['responses'], + agentFramework: 'codex', + selectedModel: '', + thinkingMode: 'auto', + runtimeCapabilityMatrix, + }); + + engine.start({ + text: 'ship the goal', + attachments: [], + sessionId: `session-${testCase.mode}`, + executionMode: testCase.mode, + }); + await waitForCalls(calls); + expect(calls[0]).toMatchObject({ + Metadata: { + agentengine: testCase.expected, + }, + }); + } + }); + + it('does not send execution-mode metadata without an explicit typed capability', async () => { + const calls: Record[] = []; + const engine = createRunEngine(createApiFacade(calls)); + engine.updateConfig({ + agentId: 'agent-live', + apiFormats: ['responses'], + agentFramework: 'codex', + selectedModel: '', + thinkingMode: 'auto', + }); + + engine.start({ + text: 'legacy request', + attachments: [], + sessionId: 'session-live', + executionMode: 'plan', + }); + await waitForCalls(calls); + expect(calls[0]).not.toMatchObject({ + Metadata: { agentengine: { collaboration_mode: 'plan' } }, + }); + }); + it('sends uploaded attachments as Responses input_file references', async () => { const calls: Record[] = []; const uploadCalls: FormData[] = []; @@ -989,6 +1076,43 @@ describe('RunEngineImpl', () => { expect(settledSessionIds).toEqual(['session-history']); }); + it('does not create a second session or replay a prompt after an empty first stream', async () => { + const calls: Record[] = []; + const createdSessions: string[] = []; + const engine = createRunEngine({ + ...createApiFacade(calls), + async createSession() { + const sessionId = `session-${createdSessions.length + 1}`; + createdSessions.push(sessionId); + return { SessionId: sessionId }; + }, + async runAgent(body) { + calls.push(body); + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + }, + }); + + engine.updateConfig({ + agentId: 'agent-live', + apiFormats: ['responses'], + agentFramework: 'hermes', + selectedModel: '', + thinkingMode: 'auto', + }); + + expect(engine.start({ text: '你好', attachments: [] })).toBe(true); + await waitForCalls(calls); + await waitForEngineIdle(engine); + + expect(createdSessions).toEqual(['session-1']); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ SessionId: 'session-1' }); + }); + it('keeps response.failed as failed instead of overwriting it as completed', async () => { const calls: Record[] = []; const engine = createRunEngine({ @@ -1156,3 +1280,108 @@ describe('RunEngineImpl', () => { }); }); }); + +describe('RunEngineImpl agent control receipts', () => { + function createControlFacade(receipt: unknown) { + const api = createApiFacade([]) as ApiFacade & { + submitControl?: (command: unknown) => Promise; + subscribeSessionEvents?: (sessionId: string, afterSeq: number) => Promise>; + getAgentStatus?: () => Promise; + }; + api.submitControl = vi.fn().mockResolvedValue(receipt); + api.subscribeSessionEvents = vi.fn().mockResolvedValue(new ReadableStream()); + api.getAgentStatus = vi.fn().mockResolvedValue(null); + return api; + } + + function enqueueCommand(engine: RunEngineImpl, idempotencyKey = 'idem-1') { + return engine.submitControl({ + command_type: 'enqueue', + idempotency_key: idempotencyKey, + payload: { content: 'hello' }, + }); + } + + it('does not show accepted as completed', async () => { + const api = createControlFacade({ + schema_version: 1, + command_id: '4bf84e1b-f4cd-4c55-907f-2dc5e676b119', + status: 'accepted', + message_id: '11111111-2222-3333-4444-555555555555', + }); + const engine = createRunEngine(api); + await enqueueCommand(engine); + expect(engine.controlState.phase).toBe('queued'); + expect(engine.stage).not.toBe('idle-idle'); + }); + + it('treats duplicate receipts as queued', async () => { + const api = createControlFacade({ + schema_version: 1, + command_id: '4bf84e1b-f4cd-4c55-907f-2dc5e676b119', + status: 'duplicate', + message_id: '11111111-2222-3333-4444-555555555555', + }); + const engine = createRunEngine(api); + await enqueueCommand(engine); + expect(engine.controlState.phase).toBe('queued'); + }); + + it('marks queue_full retryable and reuses the same idempotency key', async () => { + const api = createControlFacade({ + schema_version: 1, + command_id: '4bf84e1b-f4cd-4c55-907f-2dc5e676b119', + status: 'queue_full', + error: { code: 'queue_full', message: 'inbox depth reached limit', retryable: true, details: { limit: 128 } }, + }); + const engine = createRunEngine(api); + await enqueueCommand(engine, 'idem-retry'); + expect(engine.controlState.phase).toBe('retryable'); + expect(engine.controlState.retryKey).toBe('idem-retry'); + await engine.retryControl(); + expect(api.submitControl).toHaveBeenCalledTimes(2); + const first = (api.submitControl as ReturnType).mock.calls[0][0] as { idempotency_key: string }; + const second = (api.submitControl as ReturnType).mock.calls[1][0] as { idempotency_key: string }; + expect(second.idempotency_key).toBe(first.idempotency_key); + }); + + it('disables unsupported commands and surfaces the capability reason', async () => { + const api = createControlFacade({ + schema_version: 1, + command_id: '4bf84e1b-f4cd-4c55-907f-2dc5e676b119', + status: 'unsupported', + error: { code: 'unsupported', message: 'steer not supported by runtime', retryable: false, details: {} }, + }); + const engine = createRunEngine(api); + await engine.submitControl({ + command_type: 'steer', + idempotency_key: 'idem-steer', + payload: { content: 'turn left' }, + }); + expect(engine.controlState.phase).toBe('unsupported'); + expect(engine.controlState.error?.message).toBe('steer not supported by runtime'); + }); + + it('queries status before retrying after persistence_uncertain', async () => { + const api = createControlFacade({ + schema_version: 1, + command_id: '4bf84e1b-f4cd-4c55-907f-2dc5e676b119', + status: 'persistence_uncertain', + error: { code: 'persistence_uncertain', message: 'commit result unknown', retryable: true, details: {} }, + }); + const engine = createRunEngine(api); + await enqueueCommand(engine, 'idem-uncertain'); + expect(engine.controlState.phase).toBe('confirming'); + await engine.retryControl(); + expect(api.getAgentStatus).toHaveBeenCalledTimes(1); + }); + + it('stops mutations on a contract mismatch receipt', async () => { + const api = createControlFacade({ schema_version: 2, command_id: 'x', status: 'accepted' }); + const engine = createRunEngine(api); + const state = await enqueueCommand(engine); + expect(state.phase).toBe('contract_mismatch'); + // A malformed receipt must not mutate run state further. + expect(state.receipt).toBeNull(); + }); +}); diff --git a/src/__tests__/runtime-items.test.ts b/src/__tests__/runtime-items.test.ts new file mode 100644 index 0000000..c861cfc --- /dev/null +++ b/src/__tests__/runtime-items.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from 'vitest'; + +import { + RuntimeItemReducer, + legacyAssistantIdentity, + projectRuntimeItems, + responsesEventToItemOperations, + sessionEventToItemOperation, + type RuntimeItemOperation, +} from '../core/stream/runtime-items.js'; + +const RUN = 'run-1'; +const SCOPE = 'scope-1'; + +function start(itemId: string, scopeId = SCOPE): RuntimeItemOperation { + return { + type: 'item_started', + runId: RUN, + scopeId, + itemId, + itemKind: 'message', + phase: 'final_answer', + }; +} + +function append(itemId: string, text: string, scopeId = SCOPE): RuntimeItemOperation { + return { + type: 'item_updated', + runId: RUN, + scopeId, + itemId, + partId: 'text-0', + op: 'append', + part: { partId: 'text-0', contentType: 'text', text }, + }; +} + +function replace(itemId: string, text: string, scopeId = SCOPE): RuntimeItemOperation { + return { + type: 'item_snapshot_replaced', + runId: RUN, + scopeId, + itemId, + parts: [{ partId: 'text-0', contentType: 'text', text }], + }; +} + +function complete(itemId: string, text: string, scopeId = SCOPE): RuntimeItemOperation { + return { + type: 'item_completed', + runId: RUN, + scopeId, + itemId, + parts: [{ partId: 'text-0', contentType: 'text', text }], + }; +} + +function twoCompletedItems(text: string): RuntimeItemReducer { + const reducer = new RuntimeItemReducer(); + reducer.apply(start('item-1')); + reducer.apply(complete('item-1', text)); + reducer.apply(start('item-2')); + reducer.apply(complete('item-2', text)); + return reducer; +} + +function project(reducer: RuntimeItemReducer) { + return projectRuntimeItems(reducer.snapshot()); +} + +describe('RuntimeItemReducer', () => { + it('replaces a completed snapshot without appending it twice', () => { + const reducer = new RuntimeItemReducer(); + reducer.apply(start('item-1')); + reducer.apply(append('item-1', 'hel')); + reducer.apply(complete('item-1', 'hello')); + expect(reducer.snapshot().items[0].parts[0].text).toBe('hello'); + }); + + it('preserves identical text from distinct item ids', () => { + expect(project(twoCompletedItems('same')).map((item) => item.text)).toEqual(['same', 'same']); + }); + + it('appends deltas onto the same part by identity', () => { + const reducer = new RuntimeItemReducer(); + reducer.apply(start('item-1')); + reducer.apply(append('item-1', 'he')); + reducer.apply(append('item-1', 'llo')); + expect(reducer.snapshot().items[0].parts[0].text).toBe('hello'); + }); + + it('replaces a streaming snapshot instead of merging text', () => { + const reducer = new RuntimeItemReducer(); + reducer.apply(start('item-1')); + reducer.apply(append('item-1', 'hel')); + reducer.apply(replace('item-1', 'hello w')); + reducer.apply(replace('item-1', 'hello world')); + expect(reducer.snapshot().items[0].parts[0].text).toBe('hello world'); + }); + + it('treats a repeated event id as an idempotent replay no-op', () => { + const reducer = new RuntimeItemReducer(); + const delta: RuntimeItemOperation = { ...append('item-1', 'hel'), eventId: 'evt-1', seq: 1 }; + reducer.apply(start('item-1')); + reducer.apply(delta); + reducer.apply(delta); + reducer.apply({ ...append('item-1', 'lo'), eventId: 'evt-2', seq: 2 }); + expect(reducer.snapshot().items[0].parts[0].text).toBe('hello'); + }); + + it('keeps interleaved subagent scopes independent', () => { + const reducer = new RuntimeItemReducer(); + reducer.apply(start('item-a', 'scope-a')); + reducer.apply(start('item-b', 'scope-b')); + reducer.apply(append('item-a', 'A1', 'scope-a')); + reducer.apply(append('item-b', 'B1', 'scope-b')); + reducer.apply(append('item-a', 'A2', 'scope-a')); + reducer.apply(complete('item-b', 'B1-done', 'scope-b')); + const snapshot = reducer.snapshot(); + const itemA = snapshot.items.find((item) => item.itemId === 'item-a'); + const itemB = snapshot.items.find((item) => item.itemId === 'item-b'); + expect(itemA?.parts[0].text).toBe('A1A2'); + expect(itemA?.status).toBe('open'); + expect(itemB?.parts[0].text).toBe('B1-done'); + expect(itemB?.status).toBe('completed'); + }); + + it('rebuilds the same projection on refresh replay and cursor reconnect', () => { + const log: RuntimeItemOperation[] = [ + { type: 'run_started', runId: RUN, eventId: 'e0', seq: 0 }, + { ...start('item-1'), eventId: 'e1', seq: 1 }, + { ...append('item-1', 'hel'), eventId: 'e2', seq: 2 }, + { ...append('item-1', 'lo'), eventId: 'e3', seq: 3 }, + { ...complete('item-1', 'hello'), eventId: 'e4', seq: 4 }, + { type: 'run_completed', runId: RUN, eventId: 'e5', seq: 5 }, + ]; + const live = new RuntimeItemReducer(); + live.applyAll(log); + + // Refresh replay: a brand new reducer over the full log. + const replayed = new RuntimeItemReducer(); + replayed.applyAll(log); + + // Cursor reconnect: re-applying the tail to the live reducer is a no-op. + live.applyAll(log.slice(2)); + + expect(replayed.snapshot()).toEqual(live.snapshot()); + expect(live.snapshot().items[0].parts[0].text).toBe('hello'); + expect(live.snapshot().status).toBe('completed'); + }); +}); + +describe('sessionEventToItemOperation', () => { + it('maps canonical Metadata.RuntimeItem assistant_message to a completed item', () => { + const operation = sessionEventToItemOperation({ + EventType: 'assistant_message', + InvocationId: 'inv-1', + EventId: 'evt-assistant', + Content: { parts: [{ text: '最终答案' }] }, + Metadata: { + RuntimeItem: { + RunId: 'run-9', + ScopeId: 'scope-9', + ItemId: 'item-9', + PartId: 'text-0', + Operation: 'replace', + SourceEventId: 'native-1', + }, + }, + }); + expect(operation).toMatchObject({ + type: 'item_completed', + runId: 'run-9', + scopeId: 'scope-9', + itemId: 'item-9', + eventId: 'native-1', + }); + }); + + it('maps canonical deltas to append/replace by Operation without touching text', () => { + const base = { + InvocationId: 'inv-1', + Content: { parts: [{ text: 'chunk' }] }, + Metadata: { + RuntimeItem: { + RunId: 'run-9', + ScopeId: 'scope-9', + ItemId: 'item-9', + PartId: 'text-0', + SourceEventId: 'native-2', + }, + }, + }; + const appended = sessionEventToItemOperation({ + ...base, + EventType: 'assistant_stream_delta', + Metadata: { RuntimeItem: { ...base.Metadata.RuntimeItem, Operation: 'append' } }, + }); + expect(appended).toMatchObject({ type: 'item_updated', op: 'append' }); + + const replaced = sessionEventToItemOperation({ + ...base, + EventType: 'assistant_stream_delta', + Metadata: { RuntimeItem: { ...base.Metadata.RuntimeItem, Operation: 'replace' } }, + }); + expect(replaced).toMatchObject({ type: 'item_updated', op: 'replace' }); + }); + + it('maps legacy assistant_stream_snapshot to a replace on the synthesized identity', () => { + const operation = sessionEventToItemOperation({ + EventType: 'assistant_stream_snapshot', + InvocationId: 'inv-1', + EventId: 'evt-snap', + Content: { parts: [{ text: 'partial' }] }, + }); + expect(operation).toMatchObject({ + type: 'item_snapshot_replaced', + scopeId: 'inv-1', + itemId: 'inv-1:legacy-assistant', + }); + }); + + it('maps legacy assistant_message to a complete on the synthesized identity', () => { + const operation = sessionEventToItemOperation({ + EventType: 'assistant_message', + InvocationId: 'inv-1', + EventId: 'evt-final', + Content: { parts: [{ text: 'done' }] }, + }); + expect(operation).toMatchObject({ + type: 'item_completed', + scopeId: 'inv-1', + itemId: 'inv-1:legacy-assistant', + }); + }); + + it('reduces a legacy snapshot-then-final run to one assistant item', () => { + const reducer = new RuntimeItemReducer(); + const events = [ + { + EventType: 'assistant_stream_snapshot', + InvocationId: 'inv-1', + EventId: 'e1', + Content: { parts: [{ text: 'hel' }] }, + }, + { + EventType: 'assistant_stream_snapshot', + InvocationId: 'inv-1', + EventId: 'e2', + Content: { parts: [{ text: 'hello' }] }, + }, + { + EventType: 'assistant_message', + InvocationId: 'inv-1', + EventId: 'e3', + Content: { parts: [{ text: 'hello' }] }, + }, + ]; + for (const event of events) { + const operation = sessionEventToItemOperation(event); + if (operation) reducer.apply(operation); + } + const snapshot = reducer.snapshot(); + expect(snapshot.items).toHaveLength(1); + expect(snapshot.items[0].parts[0].text).toBe('hello'); + expect(snapshot.items[0].status).toBe('completed'); + }); + + it('synthesizes the legacy identity from the invocation id only', () => { + expect(legacyAssistantIdentity('inv-1')).toEqual({ + scopeId: 'inv-1', + itemId: 'inv-1:legacy-assistant', + }); + }); +}); + +describe('responsesEventToItemOperations', () => { + it('keys a Responses message item by its output item id', () => { + const operations = [ + ...responsesEventToItemOperations( + 'response.output_item.added', + { item: { id: 'msg_1', type: 'message' } }, + RUN, + SCOPE, + ), + ...responsesEventToItemOperations( + 'response.output_text.delta', + { item_id: 'msg_1', delta: 'hel' }, + RUN, + SCOPE, + ), + ...responsesEventToItemOperations( + 'response.output_item.done', + { item: { id: 'msg_1', type: 'message', content: [{ type: 'output_text', text: 'hello' }] } }, + RUN, + SCOPE, + ), + ]; + const reducer = new RuntimeItemReducer(); + reducer.applyAll(operations); + const snapshot = reducer.snapshot(); + expect(snapshot.items).toHaveLength(1); + expect(snapshot.items[0].itemId).toBe('msg_1'); + expect(snapshot.items[0].parts[0].text).toBe('hello'); + expect(snapshot.items[0].status).toBe('completed'); + }); + + it('keeps two Responses output items with identical text distinct', () => { + const reducer = new RuntimeItemReducer(); + for (const itemId of ['msg_1', 'msg_2']) { + reducer.applyAll( + responsesEventToItemOperations( + 'response.output_item.added', + { item: { id: itemId, type: 'message' } }, + RUN, + SCOPE, + ), + ); + reducer.applyAll( + responsesEventToItemOperations( + 'response.output_item.done', + { item: { id: itemId, type: 'message', content: [{ type: 'output_text', text: 'same' }] } }, + RUN, + SCOPE, + ), + ); + } + const snapshot = reducer.snapshot(); + expect(snapshot.items.map((item) => item.itemId)).toEqual(['msg_1', 'msg_2']); + expect(projectRuntimeItems(snapshot).map((item) => item.text)).toEqual(['same', 'same']); + }); +}); diff --git a/src/__tests__/session-event-history.test.ts b/src/__tests__/session-event-history.test.ts index 9c64c97..76a8685 100644 --- a/src/__tests__/session-event-history.test.ts +++ b/src/__tests__/session-event-history.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + createSessionEventCursor, loadCompleteSessionEventHistory, resolveOlderSessionEventPage, } from '../utils/session-event-history.js'; @@ -94,3 +95,56 @@ describe('session event history loading', () => { expect(resolveOlderSessionEventPage({ offset: 306, total: 306 }, 50)).toBeNull(); }); }); + +function kernelEvent(seq: number, extra: Record = {}) { + return { + schema_version: 1, + event_id: `00000000-0000-4000-8000-${String(seq).padStart(12, '0')}`, + session_id: 'session-1', + seq, + timestamp: '2026-08-17T00:00:01Z', + family: 'runtime', + family_version: 2, + event_type: 'run.message.delta', + payload: { seq }, + ...extra, + }; +} + +describe('SessionEventCursor (agent-kernel/v1)', () => { + it('dedupes and orders strictly by session seq', () => { + const cursor = createSessionEventCursor(); + cursor.accept(kernelEvent(3)); + cursor.accept(kernelEvent(1)); + cursor.accept(kernelEvent(2)); + cursor.accept(kernelEvent(2)); // exact duplicate is ignored + expect(cursor.displayableEvents().map((event) => event.seq)).toEqual([1, 2, 3]); + expect(cursor.lastSeq).toBe(3); + }); + + it('keeps unknown event families out of display but advances the cursor', () => { + const cursor = createSessionEventCursor(); + cursor.accept(kernelEvent(1)); + cursor.accept(kernelEvent(2, { family: 'workflow', event_type: 'step.started' })); + cursor.accept(kernelEvent(3)); + expect(cursor.displayableEvents().map((event) => event.seq)).toEqual([1, 3]); + expect(cursor.lastSeq).toBe(3); + }); + + it('replays refresh results before live events without duplicating seqs', () => { + const cursor = createSessionEventCursor(); + cursor.accept(kernelEvent(1)); + cursor.accept(kernelEvent(2)); + // Simulated refresh replay overlapping the live tail. + cursor.accept(kernelEvent(2)); + cursor.accept(kernelEvent(3)); + expect(cursor.displayableEvents().map((event) => event.seq)).toEqual([1, 2, 3]); + }); + + it('reconnects from the last session seq', () => { + const cursor = createSessionEventCursor(); + cursor.accept(kernelEvent(41)); + cursor.accept(kernelEvent(42)); + expect(cursor.reconnectAfterSeq()).toBe(42); + }); +}); diff --git a/src/__tests__/session-list.test.ts b/src/__tests__/session-list.test.ts new file mode 100644 index 0000000..07a9cbe --- /dev/null +++ b/src/__tests__/session-list.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveCompactSessionMeta } from '../utils/session-list.js'; + +describe('hosted session list status projection', () => { + it('keeps completed sessions silent and uses the updated time as ordinary metadata', () => { + expect(resolveCompactSessionMeta({ + ActiveRunStatus: 'completed', + UpdatedAt: '2026-05-07T08:45:00Z', + })).toEqual({ + running: false, + failed: false, + label: '5月7日 16:45', + }); + }); + + it('projects active and failed statuses without exposing raw status text', () => { + const now = Date.parse('2026-05-07T08:46:00Z'); + + expect(resolveCompactSessionMeta({ + ActiveRunStatus: 'resuming', + UpdatedAt: '2026-05-07T08:45:00Z', + }, { now })).toEqual({ + running: true, + failed: false, + label: '', + }); + expect(resolveCompactSessionMeta({ + ActiveRunStatus: 'resume_failed', + UpdatedAt: '2026-05-07T08:45:00Z', + })).toEqual({ + running: false, + failed: true, + label: '', + }); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts index 250d54e..5ed37d4 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -32,10 +32,34 @@ export class CancelledError extends Error { const API_BASE = '/agentengine/api/v1'; async function parseActionResponse(response: Response): Promise { - let data: Record; + const raw = await response.text(); + let data: Record = {}; try { - data = await response.json(); + data = raw ? JSON.parse(raw) as Record : {}; } catch { + // Hosted Agent domains deliberately return an HTML 401/403 page when a + // user opens the raw URL without the short-lived Dashboard session. This + // is an authentication boundary, not a malformed AgentEngine response. + const contentType = response.headers.get('content-type')?.toLowerCase() || ''; + const isHostedHtmlAuthPage = + (response.status === 401 || response.status === 403) + && (contentType.includes('text/html') || raw.trimStart().startsWith('<')); + if (isHostedHtmlAuthPage) { + throw new ApiError( + response.status, + '访问会话已失效,请从 Dashboard 或 Studio 的云端会话重新打开 Agent。', + ); + } + if (!response.ok) { + const plainMessage = raw.trim(); + throw new ApiError( + response.status, + plainMessage && plainMessage.length <= 256 && !plainMessage.startsWith('<') + ? plainMessage + : response.statusText || `HTTP ${response.status}`, + raw, + ); + } throw new ApiError(-2, '响应格式异常'); } @@ -155,6 +179,14 @@ export async function streamAction( throw new ApiError(response.status, `流式请求失败: ${response.statusText}`); } + const contentType = response.headers.get('content-type')?.toLowerCase() || ''; + if (contentType.includes('application/json')) { + // Action endpoints sometimes carry business failures in an HTTP 200 JSON + // envelope. Validate a clone so successful legacy JSON responses keep + // their original body while rejected admissions never masquerade as SSE. + await parseActionResponse(response.clone()); + } + if (!response.body) { throw new ApiError(-1, '无法获取响应流'); } diff --git a/src/api/events.ts b/src/api/events.ts index 5dfc439..55a0eb8 100644 --- a/src/api/events.ts +++ b/src/api/events.ts @@ -29,3 +29,21 @@ export async function listSessionEvents( Limit: Number.isFinite(Number(data.Limit)) ? Number(data.Limit) : opts?.limit ?? data.Events?.length ?? 0, }; } + +import { streamGetAction } from './client.js'; + +/** + * agent-kernel/v1 session event subscription. `afterSeq` is the unified + * Session seq cursor; reconnects resume from the last observed seq. + */ +export async function subscribeSessionEvents( + sessionId: string, + afterSeq: number, + options?: { signal?: AbortSignal }, +): Promise> { + return streamGetAction( + 'SubscribeSessionEvents', + { SessionId: sessionId, after_seq: String(afterSeq) }, + options, + ); +} diff --git a/src/api/run.ts b/src/api/run.ts index 4513f50..c4c2a10 100644 --- a/src/api/run.ts +++ b/src/api/run.ts @@ -1,8 +1,56 @@ -import { streamAction } from './client.js'; +import { streamAction, postJsonAction } from './client.js'; +import { decodeReceipt, type AgentControlReceipt } from '../types/agent-control.js'; export async function runAgent( body: Record, options?: { signal?: AbortSignal }, ): Promise> { return streamAction('RunAgent', body, options); -} \ No newline at end of file +} + +export type SubmitAgentControlParams = { + commandType: 'enqueue' | 'steer' | 'inject' | 'interrupt' | 'pause' | 'resume' | 'submit_interaction'; + idempotencyKey: string; + payload: Record; +}; + +/** + * Submit an agent-kernel/v1 AgentControlCommand and decode the receipt with + * the canonical strict decoder; malformed receipts throw + * ContractMismatchError. + */ +export async function submitAgentControl( + params: SubmitAgentControlParams, + options?: { signal?: AbortSignal }, +): Promise { + const data = await postJsonAction('SubmitAgentControl', { + CommandType: params.commandType, + IdempotencyKey: params.idempotencyKey, + Payload: params.payload, + }, options); + return decodeReceipt(data); +} + +export type SubmitInteractionParams = { + AgentId: string; + SessionId: string; + RunId: string; + InteractionId: string; + ExpectedRevision: number; + Action: 'approve' | 'reject' | 'submit' | 'cancel'; + Response: Record; + IdempotencyKey: string; +}; + +/** + * AgentEngine Interaction/v1 submission. One public action, revision CAS, + * idempotency-scoped; the receipt is decoded with the canonical strict + * decoder. + */ +export async function submitInteraction( + params: SubmitInteractionParams, + options?: { signal?: AbortSignal }, +): Promise { + const data = await postJsonAction('SubmitInteraction', { ...params }, options); + return decodeReceipt(data); +} diff --git a/src/api/session.ts b/src/api/session.ts index e037ae8..729ddcb 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -36,6 +36,16 @@ type CreateSessionResponse = { Session: SessionPayload; }; +export type DeleteSessionResult = { + /** + * `false` means the Server deliberately kept the session because its + * runtime-side deletion has not completed. Older Servers omit this field; + * their successful response retains the historical "deleted" behaviour. + */ + Deleted?: boolean; + RuntimeSync?: string; +}; + export type { SessionPayload }; export type ListSessionsOptions = { @@ -70,8 +80,11 @@ export async function createSession(agentId: string, opts?: { signal?: AbortSign return data.Session; } -export async function deleteSession(sessionId: string, opts?: { signal?: AbortSignal }): Promise { - await postJsonAction('DeleteSession', { SessionId: sessionId }, opts); +export async function deleteSession( + sessionId: string, + opts?: { signal?: AbortSignal }, +): Promise { + return postJsonAction('DeleteSession', { SessionId: sessionId }, opts); } export async function getSession( diff --git a/src/components/chat/ChatComposer.tsx b/src/components/chat/ChatComposer.tsx index 09e812f..23ffff1 100644 --- a/src/components/chat/ChatComposer.tsx +++ b/src/components/chat/ChatComposer.tsx @@ -13,10 +13,12 @@ import { cn } from '@/lib/utils'; import { useModelStore } from '@/stores/model.js'; import type { ModelStore } from '@/stores/model.js'; import type { ApprovalPolicyCapability } from '@/types/capabilities.js'; -import { normalizeThinkingMode } from '@/utils/model-options.js'; +import type { RuntimeExecutionMode } from '@/core/run/types.js'; import { ContextUsageIndicator } from './ContextUsageIndicator'; -import { MenuChip } from './MenuChip'; +import { ExecutionModeMenu } from './ExecutionModeMenu'; +import type { RuntimeExecutionModeSupport } from './ExecutionModeMenu'; +import { ModelSettingsMenu } from './ModelSettingsMenu'; import { PermissionMenu } from './PermissionMenu'; import type { ComposerContextIndicator } from './types'; @@ -28,11 +30,16 @@ type ChatComposerProps = { input: string; isMobile: boolean; isStreaming: boolean; + attachmentsEnabled?: boolean; approvalEnabled?: boolean; approvalPolicy?: ApprovalPolicyCapability; - queuedDrafts: Array<{ text: string; attachments: File[] }>; + thinkingEnabled?: boolean; + executionMode?: RuntimeExecutionMode; + executionModeSupport: RuntimeExecutionModeSupport; + queuedDrafts: Array<{ text: string; attachments: File[]; executionMode?: RuntimeExecutionMode }>; onAppendAttachments: (files: File[]) => void; onInputChange: (event: ChangeEvent) => void; + onSelectExecutionMode: (mode: RuntimeExecutionMode) => void; onPaste: (event: ClipboardEvent) => void; onRemoveAttachment: (index: number) => void; onStopGeneration: () => void; @@ -49,11 +56,16 @@ export function ChatComposer({ input, isMobile, isStreaming, + attachmentsEnabled = true, approvalEnabled = false, approvalPolicy, + thinkingEnabled = false, + executionMode, + executionModeSupport, queuedDrafts, onAppendAttachments, onInputChange, + onSelectExecutionMode, onPaste, onRemoveAttachment, onStopGeneration, @@ -61,8 +73,15 @@ export function ChatComposer({ onSubmit, textareaRef, }: ChatComposerProps) { - const placeholderText = isMobile ? '发送消息...' : '发送消息…'; + const placeholderText = executionMode === 'goal' + ? '描述需要持续完成的目标…' + : executionMode === 'plan' + ? '描述需要先规划的任务…' + : isMobile ? '发送消息...' : '发送消息…'; const activeStopTitle = onCancelRemote ? '保留恢复点并结束本次执行' : '停止生成'; + const canSubmit = executionMode === 'goal' + ? Boolean(input.trim()) + : Boolean(input.trim() || attachments.length > 0); // wework 风格:model/思考 chip 放输入框工具栏(从 model store 直读,不经 props)。 const availableModels = useModelStore((s: ModelStore) => s.availableModels); @@ -70,10 +89,6 @@ export function ChatComposer({ const thinkingMode = useModelStore((s: ModelStore) => s.thinkingMode); const setSelectedModel = useModelStore((s: ModelStore) => s.setSelectedModel); const setThinkingMode = useModelStore((s: ModelStore) => s.setThinkingMode); - const selectedModelLabel = - availableModels.find((m) => m.id === selectedModel)?.display_name || selectedModel || ''; - const thinkingLabel = (mode: 'auto' | 'enabled' | 'disabled') => - mode === 'enabled' ? '开启思考' : mode === 'disabled' ? '关闭思考' : '思考自动'; const formatFileSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; @@ -84,7 +99,7 @@ export function ChatComposer({ event.preventDefault(); event.stopPropagation(); - if (event.dataTransfer.files && event.dataTransfer.files.length > 0) { + if (attachmentsEnabled && event.dataTransfer.files && event.dataTransfer.files.length > 0) { onAppendAttachments(Array.from(event.dataTransfer.files)); } }; @@ -100,7 +115,7 @@ export function ChatComposer({ return; } const text = input.trim(); - if (!text && attachments.length === 0) return; + if (!canSubmit) return; onSubmit(text, attachments); }; @@ -151,8 +166,7 @@ export function ChatComposer({ ) : null} - {/* wework 风格:外层 surface+软阴影,内层 background+细边,圆角 26px */} -
+
{attachments.length > 0 ? (
@@ -202,75 +216,59 @@ export function ChatComposer({ onPaste={onPaste} placeholder={placeholderText} className={cn( - 'custom-scrollbar max-h-[112px] min-h-[48px] w-full resize-none overflow-y-auto border-0 bg-transparent px-0 pb-0 pt-1 text-[14px] leading-6 text-text-primary outline-none placeholder:text-text-muted/55', + 'custom-scrollbar max-h-[176px] min-h-[62px] w-full resize-none overflow-y-auto border-0 bg-transparent px-0 pb-1 pt-1 text-[15px] leading-6 text-text-primary outline-none placeholder:text-text-muted/60', isMobile ? 'text-[16px]' : 'text-[14px]', )} style={{ maxHeight: `${composerMaxHeight}px`, overflowY: 'auto' }} /> - {/* wework 风格工具栏:左组(附件+权限+model+思考) | 右组(上下文环+发送) */} -
-
- +
+
+ { + if (event.target.files && event.target.files.length > 0) { + onAppendAttachments(Array.from(event.target.files)); + event.target.value = ''; + } + }} + /> + fileInputRef.current?.click()} + /> {approvalEnabled ? : null} +
+ +
+ {composerContextIndicator ? : null} {availableModels.length > 0 ? ( - setSelectedModel(value)} - options={availableModels.map((model) => ({ - value: model.id, - label: model.display_name || model.id, - }))} + ) : null} - - setThinkingMode(normalizeThinkingMode(value) as 'auto' | 'enabled' | 'disabled') - } - options={[ - { value: 'auto', label: '思考自动' }, - { value: 'enabled', label: '开启思考' }, - { value: 'disabled', label: '关闭思考' }, - ]} - /> -
- -
- {composerContextIndicator ? : null} -
) : null} - {tool.approvalStatus === 'pending' ? ( + {tool.approvalStatus === 'pending' ? (
+
); } diff --git a/src/components/chat/ConnectedMessageList.tsx b/src/components/chat/ConnectedMessageList.tsx index 12d1ba2..0139f84 100644 --- a/src/components/chat/ConnectedMessageList.tsx +++ b/src/components/chat/ConnectedMessageList.tsx @@ -29,6 +29,7 @@ type ConnectedMessageListProps = { checkpointResumeEnabled?: boolean; onResumeCheckpoint?: (params: { sessionId: string; runId: string; checkpointId: string }) => void; onLoadOlderSessionMessages?: (sessionId: string) => Promise; + interactionRecords?: readonly import('../../core/interaction/types.js').Interaction[]; }; export function ConnectedMessageList({ @@ -44,6 +45,7 @@ export function ConnectedMessageList({ checkpointResumeEnabled = false, onResumeCheckpoint, onLoadOlderSessionMessages, + interactionRecords, }: ConnectedMessageListProps) { const messages = useMessageStore(s => s.messages); const currentSessionId = useSessionStore((s: SessionStore) => s.currentSessionId); @@ -271,6 +273,7 @@ export function ConnectedMessageList({ onOpenAttachmentPreview={openAttachmentPreview} onRespondToApproval={onRespondToApproval} onRespondToAguiApproval={onRespondToAguiApproval} + interactionRecords={interactionRecords} onSubmitFeedback={onSubmitFeedback} onSubmitAguiAction={onSubmitAguiAction} onStopGeneration={onStopGeneration} diff --git a/src/components/chat/ExecutionModeMenu.tsx b/src/components/chat/ExecutionModeMenu.tsx new file mode 100644 index 0000000..e01baee --- /dev/null +++ b/src/components/chat/ExecutionModeMenu.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from 'react'; + +import { Check, ChevronDown, ListTodo, Plus, Target, Upload } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import type { RuntimeExecutionMode } from '@/core/run/types.js'; + +export type RuntimeExecutionModeSupport = Record; + +type ExecutionModeMenuProps = { + attachmentsEnabled: boolean; + mode?: RuntimeExecutionMode; + support: RuntimeExecutionModeSupport; + onSelectMode: (mode: RuntimeExecutionMode) => void; + onUpload: () => void; +}; + +const modeOptions: Array<{ + value: RuntimeExecutionMode; + label: string; + description: string; + icon: typeof ListTodo; +}> = [ + { value: 'plan', label: '计划模式', description: '先分析并形成可执行计划', icon: ListTodo }, + { value: 'goal', label: '设定目标', description: '朝可验证的停止条件持续推进', icon: Target }, +]; + +const modeLabels: Record = { + plan: '计划模式', + goal: '目标', +}; + +export function ExecutionModeMenu({ + attachmentsEnabled, + mode, + support, + onSelectMode, + onUpload, +}: ExecutionModeMenuProps) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const visibleModes = modeOptions.filter((option) => support[option.value]); + + useEffect(() => { + if (!open) return; + const closeOnOutsidePress = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', closeOnOutsidePress); + document.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('mousedown', closeOnOutsidePress); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [open]); + + if (!attachmentsEnabled && visibleModes.length === 0) return null; + + return ( +
+ + + {mode ? ( + + ) : null} + + {open ? ( +
+ {attachmentsEnabled ? ( + + ) : null} + + {attachmentsEnabled && visibleModes.length > 0 ?
: null} + + {visibleModes.map((option) => { + const Icon = option.icon; + const selected = mode === option.value; + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/src/components/chat/InteractionA2uiSurface.tsx b/src/components/chat/InteractionA2uiSurface.tsx new file mode 100644 index 0000000..e948d71 --- /dev/null +++ b/src/components/chat/InteractionA2uiSurface.tsx @@ -0,0 +1,48 @@ +import type { A2UIClientEventMessage } from '@copilotkit/a2ui-renderer'; +import { A2UIActivityMessage } from './A2UIActivityMessage'; +import type { A2uiPresentation } from '../../core/interaction/types.js'; + +/** + * Renders a validated A2UI v0.9.1 interaction surface. Only reached when + * `validateA2uiPresentation` accepted the wire version and catalog + * digest; any unknown component fails inside the renderer and the user + * still has the explicit submit/cancel controls below. + */ +export function InteractionA2uiSurface({ + a2ui, + disabled, + onSubmit, + onCancel, +}: { + a2ui: A2uiPresentation; + disabled: boolean; + onSubmit: (payload: Record) => void; + onCancel: () => void; +}) { + return ( +
+ { + const context = (message.userAction?.context || {}) as Record; + const payload = + typeof context.payload === 'object' && context.payload !== null + ? (context.payload as Record) + : { ...context }; + onSubmit(payload); + }} + /> + {disabled ? null : ( + + )} +
+ ); +} diff --git a/src/components/chat/InteractionHistoryAnchor.tsx b/src/components/chat/InteractionHistoryAnchor.tsx new file mode 100644 index 0000000..21cca10 --- /dev/null +++ b/src/components/chat/InteractionHistoryAnchor.tsx @@ -0,0 +1,89 @@ +import { InteractionOutcomeLabel } from './InteractionTray'; +import type { Interaction } from '../../core/interaction/types.js'; + +function formatTime(value: string | null): string { + if (!value) return ''; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return value; + return new Date(parsed).toLocaleString(); +} + +/** + * Top-level keys of the interaction's request_schema, shown as a short + * read-only summary of what was asked. Never renders raw values. + */ +function requestSchemaKeys( + schema: Record | null, +): string[] { + const props = schema?.properties; + if (typeof props !== 'object' || props === null) return []; + return Object.keys(props as Record); +} + +/** + * Read-only anchor for a resolved interaction in chat history. + * Replaces historical interactive buttons: it retains the actor, + * decision time, outcome, and a redacted response summary, and expands + * into a fixed read-only snapshot (native details/summary — no buttons, + * no inputs, nothing editable). + */ +export function InteractionHistoryAnchor({ interaction }: { interaction: Interaction }) { + const terminal = + interaction.status === 'resolved' + || interaction.status === 'cancelled' + || interaction.status === 'expired'; + const schemaKeys = requestSchemaKeys(interaction.requestSchema); + + return ( +
+
+ {interaction.title} + +
+ {terminal ? ( + <> +
+ {interaction.actor ? 操作人:{interaction.actor} · : null} + {interaction.resolvedAt ? 决定时间:{formatTime(interaction.resolvedAt)} · : null} + + {interaction.responseSummary || '响应内容已脱敏'} + +
+
+ + 查看确认快照 + +
+
+ 操作者:{interaction.actor || '—'} +
+
+ 决定时间:{formatTime(interaction.resolvedAt) || '—'} +
+
+ 关键参数: + {schemaKeys.length > 0 ? ( + {schemaKeys.join('、')} + ) : ( + 无结构化参数 + )} +
+
+
+ + ) : ( +
+ 状态:{interaction.status === 'pending' ? '待处理(见输入区确认面板)' : '处理中…'} +
+ )} +
+ ); +} diff --git a/src/components/chat/InteractionSchemaForm.tsx b/src/components/chat/InteractionSchemaForm.tsx new file mode 100644 index 0000000..24a448a --- /dev/null +++ b/src/components/chat/InteractionSchemaForm.tsx @@ -0,0 +1,104 @@ +import type { InteractionRequestSchemaField } from './schema-fields.js'; +import { schemaFields } from './schema-fields.js'; + +/** + * Canonical JSON-schema form fallback. Renders only when the A2UI + * presentation cannot be validated; submission is always an explicit + * user action — never an automatic approval. + */ +export function InteractionSchemaForm({ + schema, + values, + onChange, + disabled, + onSubmit, + onCancel, +}: { + schema: Record; + values: Record; + onChange: (values: Record) => void; + disabled: boolean; + onSubmit: () => void; + onCancel: () => void; +}) { + const fields: InteractionRequestSchemaField[] = schemaFields(schema); + + return ( +
+ {fields.map((field) => ( + + ))} +
+ + +
+
+ ); +} diff --git a/src/components/chat/InteractionTray.tsx b/src/components/chat/InteractionTray.tsx new file mode 100644 index 0000000..fd90852 --- /dev/null +++ b/src/components/chat/InteractionTray.tsx @@ -0,0 +1,284 @@ +import { useState } from 'react'; +import { Check, ChevronLeft, ChevronRight, XCircle } from 'lucide-react'; +import { cn } from '../../lib/utils'; +import type { + Interaction, + InteractionAction, +} from '../../core/interaction/types.js'; +import { + validateA2uiPresentation, + type A2uiRenderMode, +} from '../../core/interaction/a2ui-validate.js'; +import { interactionIdempotencyKey } from '../../core/interaction/types.js'; +import { InteractionSchemaForm } from './InteractionSchemaForm'; +import { InteractionA2uiSurface } from './InteractionA2uiSurface'; + +export type InteractionTrayRespondInput = { + interactionId: string; + expectedRevision: number; + action: InteractionAction; + response: Record; + idempotencyKey: string; +}; + +type InteractionTrayProps = { + /** Pending (and in-flight) interactions for the current session. */ + interactions: readonly Interaction[]; + activeIndex: number; + onSelectIndex: (index: number) => void; + onRespond: (input: InteractionTrayRespondInput) => void; + /** Local pinned A2UI catalog used for digest validation. */ + localCatalog?: unknown; +}; + +function firstA2uiInputSchema( + a2ui: { messages: Array> } | null | undefined, +): Record | null { + for (const message of a2ui?.messages || []) { + const schema = + message.inputSchema ?? message.input_schema ?? message.schema; + if (typeof schema === 'object' && schema !== null) { + return schema as Record; + } + } + return null; +} + +function isExpired(interaction: Interaction): boolean { + if (!interaction.expiresAt) return false; + const expiry = Date.parse(interaction.expiresAt); + return Number.isFinite(expiry) && expiry <= Date.now(); +} + +/** + * A definitive submit rejection (e.g. first-wins interaction_already_resolved + * from another tab). The receipt never carries a terminal Interaction fact — + * only the SessionEvent does — so this is shown as a failed submit, never + * as resolved/cancelled. + */ +function readSubmitError( + interaction: Interaction, +): { code: string; message: string; retryable: boolean } | null { + const error = interaction.extensions.submit_error; + if ( + typeof error === 'object' + && error !== null + && 'code' in error + && 'message' in error + ) { + return { + code: String(error.code), + message: String(error.message), + retryable: Boolean(error.retryable), + }; + } + return null; +} + +const OUTCOME_LABEL: Record = { + approved: '已同意', + rejected: '已拒绝', + submitted: '已提交', + cancelled: '已取消', + expired: '已过期', +}; + +export function InteractionTray({ + interactions, + activeIndex, + onSelectIndex, + onRespond, + localCatalog, +}: InteractionTrayProps) { + const [comment, setComment] = useState(''); + const [formValues, setFormValues] = useState>({}); + + const active = interactions[Math.min(activeIndex, Math.max(interactions.length - 1, 0))]; + if (!active) return null; + + const expired = isExpired(active); + const submitError = readSubmitError(active); + const disabled = expired || active.status === 'resolving'; + const presentationMode = validateA2uiPresentation( + active.presentation?.a2ui, + localCatalog, + ); + // The canonical JSON schema form is also the safe fallback whenever the + // A2UI presentation cannot be validated: the schema comes from the + // interaction's request_schema, or from the A2UI message payload. + const fallbackSchema = + active.requestSchema + ?? firstA2uiInputSchema(active.presentation?.a2ui) + ?? null; + const mode: A2uiRenderMode = + presentationMode === 'a2ui' + ? 'a2ui' + : presentationMode === 'json-schema-form' && fallbackSchema + ? 'json-schema-form' + : active.requestSchema && !active.presentation + ? 'json-schema-form' + : 'basic-controls'; + const schemaForForm = + mode === 'json-schema-form' + ? fallbackSchema || active.requestSchema + : active.requestSchema; + + const respond = (action: InteractionAction, response: Record) => { + if (disabled) return; + onRespond({ + interactionId: active.interactionId, + expectedRevision: active.revision, + action, + response, + idempotencyKey: interactionIdempotencyKey(active.interactionId, active.revision), + }); + }; + + return ( +
+
+
+
+ {active.title} + {interactions.length > 1 ? ( + + {activeIndex + 1}/{interactions.length} + + ) : null} +
+ {interactions.length > 1 ? ( +
+ + +
+ ) : null} +
+ +

+ {active.message} +

+ + {expired ? ( +

+ 该确认已过期({OUTCOME_LABEL.expired}),等待运行时继续处理。 +

+ ) : null} + + {submitError ? ( +

+ 提交失败({submitError.code}):{submitError.message} + {submitError.retryable ? ',可重试。' : ''} +

+ ) : null} + + {mode === 'a2ui' && active.presentation?.a2ui ? ( + respond('submit', payload)} + onCancel={() => respond('cancel', {})} + /> + ) : mode === 'json-schema-form' && schemaForForm ? ( + respond('submit', formValues)} + onCancel={() => respond('cancel', {})} + /> + ) : ( +
+ setComment(event.target.value)} + placeholder="备注(可选)" + disabled={disabled} + data-testid="interaction-tray-comment" + className="mb-2 w-full rounded-md border border-amber-300/70 bg-white px-2 py-1.5 text-sm dark:border-amber-900/60 dark:bg-slate-950" + /> +
+ + + +
+
+ )} + + {active.status === 'resolving' ? ( +

正在提交,请稍候…

+ ) : null} +
+
+ ); +} + +export function InteractionOutcomeLabel({ outcome }: { outcome: string }) { + return ( + + {OUTCOME_LABEL[outcome] || outcome} + + ); +} diff --git a/src/components/chat/ModelSettingsMenu.tsx b/src/components/chat/ModelSettingsMenu.tsx new file mode 100644 index 0000000..d8924e7 --- /dev/null +++ b/src/components/chat/ModelSettingsMenu.tsx @@ -0,0 +1,198 @@ +import { useEffect, useRef, useState } from 'react'; + +import { Check, ChevronDown, ChevronRight } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import type { ThinkingMode } from '@/stores/model.js'; + +import type { ModelCatalogItem } from './types'; + +type SettingsPanel = 'model' | 'reasoning'; + +const reasoningOptions: Array<{ + value: ThinkingMode; + label: string; + description: string; +}> = [ + { value: 'auto', label: '自动', description: '使用模型或 Agent 的默认设置' }, + { value: 'enabled', label: '开启', description: '请求模型输出推理过程' }, + { value: 'disabled', label: '关闭', description: '不额外请求模型推理' }, +]; + +const reasoningLabels: Record = { + auto: '自动', + enabled: '开启', + disabled: '关闭', +}; + +type ModelSettingsMenuProps = { + availableModels: ModelCatalogItem[]; + selectedModel: string; + thinkingEnabled: boolean; + thinkingMode: ThinkingMode; + onSelectModel: (modelId: string) => void; + onSelectThinkingMode: (mode: ThinkingMode) => void; +}; + +export function ModelSettingsMenu({ + availableModels, + selectedModel, + thinkingEnabled, + thinkingMode, + onSelectModel, + onSelectThinkingMode, +}: ModelSettingsMenuProps) { + const [open, setOpen] = useState(false); + const [panel, setPanel] = useState('model'); + const ref = useRef(null); + const selectedModelLabel = + availableModels.find((model) => model.id === selectedModel)?.display_name + || selectedModel + || '选择模型'; + const activePanel: SettingsPanel = thinkingEnabled ? panel : 'model'; + + useEffect(() => { + if (!open) return; + const closeOnOutsidePress = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', closeOnOutsidePress); + document.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('mousedown', closeOnOutsidePress); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [open]); + + return ( +
+ + + {open ? ( +
+
+ {thinkingEnabled ? ( +
+ + +
+ ) : null} +
+ {activePanel === 'model' ? '模型' : '推理设置'} +
+ {activePanel === 'model' ? availableModels.map((model) => { + const selected = model.id === selectedModel; + return ( + + ); + }) : reasoningOptions.map((option) => { + const selected = option.value === thinkingMode; + return ( + + ); + })} +
+ +
+ + {thinkingEnabled ? ( + + ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/chat/PermissionMenu.tsx b/src/components/chat/PermissionMenu.tsx index 2e80d9d..df0e857 100644 --- a/src/components/chat/PermissionMenu.tsx +++ b/src/components/chat/PermissionMenu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Check, ChevronDown, Hand, ShieldAlert, ShieldCheck } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -49,8 +49,16 @@ export function PermissionMenu({ approvalPolicy }: PermissionMenuProps) { const ref = useRef(null); const permissionMode = usePermissionStore((state) => state.permissionMode); const setPermissionMode = usePermissionStore((state) => state.setPermissionMode); - const visibleOptions = options.filter( - (option) => !approvalPolicy || approvalPolicy.Modes.includes(option.value), + const TriggerIcon = permissionMode === 'ask' + ? Hand + : permissionMode === 'full' + ? ShieldAlert + : ShieldCheck; + const visibleOptions = useMemo( + () => options.filter( + (option) => !approvalPolicy || approvalPolicy.Modes.includes(option.value), + ), + [approvalPolicy], ); useEffect(() => { @@ -81,12 +89,15 @@ export function PermissionMenu({ approvalPolicy }: PermissionMenuProps) { diff --git a/src/components/chat/ProcessingBlocksView.tsx b/src/components/chat/ProcessingBlocksView.tsx index b85bbfa..765811d 100644 --- a/src/components/chat/ProcessingBlocksView.tsx +++ b/src/components/chat/ProcessingBlocksView.tsx @@ -13,6 +13,8 @@ import { MessageMarkdown } from '../MessageMarkdown'; import { parseUnifiedDiff, summarizeDiffSection, isUnifiedDiff } from '../../utils/parse-unified-diff'; import type { Message } from './types'; import type { ProcessingBlock, ThinkingBlock, ToolBlock, TextBlock } from '../../core/run/blocks'; +import { InteractionHistoryAnchor } from './InteractionHistoryAnchor'; +import type { Interaction } from '../../core/interaction/types'; type ToolData = NonNullable[string]; @@ -21,6 +23,8 @@ interface Props { isStreaming: boolean; onRespondToApproval?: (p: { approvalRequestId: string; approve: boolean; previousResponseId?: string }) => void; onRespondToAguiApproval?: (p: { interruptId: string; approve: boolean }) => void; + /** Interaction/v1 records; when present the read-only anchor replaces inline approval buttons. */ + interactionRecords?: readonly Interaction[]; } /** 折叠容器:单行 summary + 可展开详情,260ms 高度动画。 */ @@ -107,12 +111,15 @@ function ToolRow({ block, tool, isStreaming, + interactionRecord, onRespondToApproval, onRespondToAguiApproval, }: { block: ToolBlock; tool?: ToolData; isStreaming: boolean; + /** The composer tray owns all interaction decisions once normalized. */ + interactionRecord?: Interaction; onRespondToApproval?: Props['onRespondToApproval']; onRespondToAguiApproval?: Props['onRespondToAguiApproval']; }) { @@ -169,7 +176,7 @@ function ToolRow({ } >
- {approvalRequestId && approvalStatus === 'pending' && ( + {approvalRequestId && approvalStatus === 'pending' && !interactionRecord && (
)} + {approvalRequestId && approvalStatus === 'pending' && interactionRecord ? ( +
+
+ ) : null} {approvalRequestId && approvalStatus && approvalStatus !== 'pending' && (
{approvalStatus === 'approved' ? '已授权' : '已拒绝'} @@ -361,6 +374,7 @@ export function ProcessingBlocksView({ isStreaming, onRespondToApproval, onRespondToAguiApproval, + interactionRecords, }: Props) { const blocks: ProcessingBlock[] = message.blocks ?? []; return ( @@ -370,15 +384,27 @@ export function ProcessingBlocksView({ return ; } if (block.type === 'tool') { + const blockExtra = (block.extra || {}) as Record; + const approvalId = String( + blockExtra.approvalRequestId + || message.tools?.[block.toolName]?.approvalRequestId + || '', + ); + const record = approvalId + ? interactionRecords?.find((entry) => entry.interactionId === approvalId) + : undefined; return ( - +
+ + {record ? : null} +
); } return ; diff --git a/src/components/chat/schema-fields.ts b/src/components/chat/schema-fields.ts new file mode 100644 index 0000000..a014df7 --- /dev/null +++ b/src/components/chat/schema-fields.ts @@ -0,0 +1,42 @@ +export type InteractionRequestSchemaField = { + name: string; + title?: string; + type: 'string' | 'number' | 'boolean' | 'unknown'; + required: boolean; + enumValues?: unknown[]; +}; + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Extract renderable fields from a canonical JSON schema + * (`{type:"object", properties, required}`). + */ +export function schemaFields(schema: Record): InteractionRequestSchemaField[] { + const properties = asRecord(schema.properties) || {}; + const required = Array.isArray(schema.required) + ? new Set(schema.required.map(String)) + : new Set(); + + const fields: InteractionRequestSchemaField[] = []; + for (const [name, raw] of Object.entries(properties)) { + const property = asRecord(raw); + const rawType = String(property?.type || '').toLowerCase(); + const type: InteractionRequestSchemaField['type'] = + rawType === 'string' || rawType === 'number' || rawType === 'boolean' + ? (rawType as 'string' | 'number' | 'boolean') + : 'unknown'; + fields.push({ + name, + title: typeof property?.title === 'string' ? property.title : undefined, + type, + required: required.has(name), + enumValues: Array.isArray(property?.enum) ? property.enum : undefined, + }); + } + return fields; +} diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index d7124b4..6c72900 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -21,6 +21,14 @@ export type Message = { traceId?: string; rootSpanId?: string; eventType?: string; + /** + * schema v2 runtime item identity. 存在时渲染/反馈按 runId/scopeId/itemId/partId + * 归并,替代 v1 的文本启发式去重。 + */ + runId?: string; + scopeId?: string; + itemId?: string; + partId?: string; status?: 'running' | 'completed' | 'failed' | 'cancelled'; summary?: string; trigger?: string; diff --git a/src/components/native/NativeTerminalPanel.tsx b/src/components/native/NativeTerminalPanel.tsx index 9a57e39..945c3eb 100644 --- a/src/components/native/NativeTerminalPanel.tsx +++ b/src/components/native/NativeTerminalPanel.tsx @@ -172,6 +172,26 @@ export function NativeTerminalPanel({ } }, [capability.Enabled, open]); + // When the control-plane sessionId changes, the terminal sessions bound to + // the previous sessionId are stale (their pty processes keep running and + // leak). Close them server-side before refreshing for the new sessionId. + const prevSessionIdRef = useRef(undefined); + useEffect(() => { + if (prevSessionIdRef.current !== undefined && prevSessionIdRef.current !== sessionId) { + const stale = terminalSessionsRef.current; + setTerminalSessions([]); + setActiveTerminalSessionId(null); + void Promise.all( + stale.map((session) => + fetch(`${TERMINAL_SESSIONS_ENDPOINT}/${session.terminal_session_id}`, { + method: 'DELETE', + }).catch(() => {}), + ), + ); + } + prevSessionIdRef.current = sessionId; + }, [sessionId]); + const attachTerminalSession = useCallback(async (session: TerminalSession) => { if (!containerRef.current) { return () => {}; diff --git a/src/core/api/facade.ts b/src/core/api/facade.ts index 57aed5c..9b88423 100644 --- a/src/core/api/facade.ts +++ b/src/core/api/facade.ts @@ -3,7 +3,8 @@ import { postJsonAction, streamGetAction } from '../../api/client.js'; import { listSessions as listSessionsApi, createSession as createSessionApi, deleteSession as deleteSessionApi, getSession as getSessionApi } from '../../api/session.js'; import { listSessionEvents as listSessionEventsApi } from '../../api/events.js'; import { listSessionMessages as listSessionMessagesApi } from '../../api/messages.js'; -import { runAgent as runAgentApi } from '../../api/run.js'; +import { runAgent as runAgentApi, submitAgentControl as submitAgentControlApi, submitInteraction as submitInteractionApi } from '../../api/run.js'; +import { subscribeSessionEvents as subscribeSessionEventsApi } from '../../api/events.js'; import { listSessionCheckpoints as listSessionCheckpointsApi, listToolReceipts as listToolReceiptsApi, previewCheckpointResume as previewCheckpointResumeApi, resumeRun as resumeRunApi } from '../../api/checkpoints.js'; import { listWorkspaceFiles as listWorkspaceFilesApi, addWorkspaceFile as addWorkspaceFileApi, deleteWorkspaceFile as deleteWorkspaceFileApi, getWorkspaceFileContent as getFileContentApi } from '../../api/workspace.js'; import { listAgentModels as listAgentModelsApi } from '../../api/model.js'; @@ -22,7 +23,7 @@ export class ApiFacadeImpl implements ApiFacade { } async deleteSession(sessionId: string, opts?: { signal?: AbortSignal }) { - await deleteSessionApi(sessionId, opts); + return deleteSessionApi(sessionId, opts); } async getSession(sessionId: string, opts?: { signal?: AbortSignal }) { @@ -94,6 +95,49 @@ export class ApiFacadeImpl implements ApiFacade { return streamGetAction('SubscribeRunEvents', qs, opts); } + async submitControl( + command: { + command_type: 'enqueue' | 'steer' | 'inject' | 'interrupt' | 'pause' | 'resume' | 'submit_interaction'; + idempotency_key: string; + payload: Record; + }, + opts?: { signal?: AbortSignal }, + ) { + return submitAgentControlApi({ + commandType: command.command_type, + idempotencyKey: command.idempotency_key, + payload: command.payload, + }, opts); + } + + async submitInteraction( + params: { + AgentId: string; + SessionId: string; + RunId: string; + InteractionId: string; + ExpectedRevision: number; + Action: 'approve' | 'reject' | 'submit' | 'cancel'; + Response: Record; + IdempotencyKey: string; + }, + opts?: { signal?: AbortSignal }, + ) { + return submitInteractionApi(params, opts); + } + + async getAgentStatus(opts?: { signal?: AbortSignal }) { + return postJsonAction('GetAgentStatus', {}, opts); + } + + async subscribeSessionEvents( + sessionId: string, + afterSeq: number, + opts?: { signal?: AbortSignal }, + ) { + return subscribeSessionEventsApi(sessionId, afterSeq, opts); + } + async cancelRun(agentId: string, sessionId: string, invocationId: string, opts?: { signal?: AbortSignal }) { return postJsonAction( 'CancelRun', diff --git a/src/core/api/types.ts b/src/core/api/types.ts index 2f36fde..2268957 100644 --- a/src/core/api/types.ts +++ b/src/core/api/types.ts @@ -7,7 +7,10 @@ export interface ApiFacade { PageSize?: number; }>; createSession(agentId: string, opts?: { signal?: AbortSignal }): Promise<{ SessionId: string }>; - deleteSession(sessionId: string, opts?: { signal?: AbortSignal }): Promise; + deleteSession(sessionId: string, opts?: { signal?: AbortSignal }): Promise<{ + Deleted?: boolean; + RuntimeSync?: string; + }>; getSession(sessionId: string, opts?: { signal?: AbortSignal }): Promise<{ SessionId: string; AgentId?: string; @@ -58,6 +61,24 @@ export interface ApiFacade { resumeRun(params: { agentId: string; sessionId: string; runId: string; checkpointId: string; resumeAttemptId?: string; invocationId?: string }, opts?: { signal?: AbortSignal }): Promise>; subscribeRunEvents(params: { sessionId: string; invocationId: string; afterSeqId: number }, opts?: { signal?: AbortSignal }): Promise>; cancelRun(agentId: string, sessionId: string, invocationId: string, opts?: { signal?: AbortSignal }): Promise; + // agent-kernel/v1 control surface + submitControl(command: { + command_type: 'enqueue' | 'steer' | 'inject' | 'interrupt' | 'pause' | 'resume' | 'submit_interaction'; + idempotency_key: string; + payload: Record; + }, opts?: { signal?: AbortSignal }): Promise; + submitInteraction(params: { + AgentId: string; + SessionId: string; + RunId: string; + InteractionId: string; + ExpectedRevision: number; + Action: 'approve' | 'reject' | 'submit' | 'cancel'; + Response: Record; + IdempotencyKey: string; + }, opts?: { signal?: AbortSignal }): Promise; + getAgentStatus(opts?: { signal?: AbortSignal }): Promise; + subscribeSessionEvents(sessionId: string, afterSeq: number, opts?: { signal?: AbortSignal }): Promise>; // Feedback getResponseFeedback(payload: Record, opts?: { signal?: AbortSignal }): Promise; diff --git a/src/core/interaction/a2ui-validate.ts b/src/core/interaction/a2ui-validate.ts new file mode 100644 index 0000000..7ead2e9 --- /dev/null +++ b/src/core/interaction/a2ui-validate.ts @@ -0,0 +1,95 @@ +/** + * A2UI v0.9.1 presentation validation with a safe fallback ladder. + * + * The production wire is locked to v0.9.1 with a pinned catalog digest. + * When the wire version or catalog digest does not match, the tray falls + * back to the canonical JSON schema form; when that is unsupported it + * falls back to plain approve/reject/text controls. A validation failure + * is never mapped to an approval. + */ +import type { A2uiPresentation } from './types.js'; + +export const A2UI_WIRE_VERSION = '0.9.1'; + +function componentIds(catalog: unknown): string[] { + const components = + typeof catalog === 'object' && catalog !== null + ? (catalog as { components?: unknown }).components + : null; + const entries = Array.isArray(components) ? components : []; + const ids: string[] = []; + for (const entry of entries) { + if ( + typeof entry === 'object' + && entry !== null + && typeof (entry as { id?: unknown }).id === 'string' + ) { + ids.push((entry as { id: string }).id); + } + } + return ids.sort(); +} + +/** Deterministic digest of the local A2UI catalog. */ +export function computeA2uiCatalogDigest(catalog: unknown): string { + const ids = componentIds(catalog).join('|'); + // FNV-1a 64-bit style hash over the sorted component ids — enough to + // detect a catalog mismatch, not a security primitive. + let hash1 = 0x811c9dc5; + let hash2 = 0x01000193; + for (let i = 0; i < ids.length; i += 1) { + const code = ids.charCodeAt(i); + hash1 = (hash1 ^ code) >>> 0; + hash1 = Math.imul(hash1, 0x01000193) >>> 0; + hash2 = (hash2 + Math.imul(code + i, 0x85ebca6b)) >>> 0; + } + return `fnv1a-${hash1.toString(16)}-${hash2.toString(16)}`; +} + +export type A2uiRenderMode = + | 'a2ui' + | 'json-schema-form' + | 'basic-controls'; + +/** + * Decide how an Interaction's presentation may render. Unknown components + * or a catalog mismatch never yield 'a2ui'; the safest usable fallback + * wins. + */ +export function validateA2uiPresentation( + presentation: A2uiPresentation | null | undefined, + localCatalog: unknown, +): A2uiRenderMode { + if (!presentation) return basicFallbackFor(null); + if (presentation.wireVersion !== A2UI_WIRE_VERSION) { + return basicFallbackFor(presentation); + } + if (!presentation.messages || presentation.messages.length === 0) { + return basicFallbackFor(presentation); + } + if (presentation.catalogDigest) { + const localDigest = computeA2uiCatalogDigest(localCatalog); + if (presentation.catalogDigest !== localDigest) { + // Catalog mismatch: never render the unknown catalog and never + // auto-approve anything. + return 'json-schema-form'; + } + } + return 'a2ui'; +} + +function basicFallbackFor( + presentation: A2uiPresentation | null | undefined, +): A2uiRenderMode { + return presentation && hasUsableSchema(presentation) ? 'json-schema-form' : 'basic-controls'; +} + +function hasUsableSchema(presentation: A2uiPresentation): boolean { + return presentation.messages.some((message) => { + const schema = + (message as { inputSchema?: unknown }).inputSchema + ?? (message as { input_schema?: unknown }).input_schema + ?? (message as { schema?: unknown }).schema; + return typeof schema === 'object' && schema !== null; + }); +} diff --git a/src/core/interaction/adapters/agui.ts b/src/core/interaction/adapters/agui.ts new file mode 100644 index 0000000..f08bc4e --- /dev/null +++ b/src/core/interaction/adapters/agui.ts @@ -0,0 +1,46 @@ +/** + * AG-UI transport adapter. + * + * Normalizes an AG-UI interrupt-driven approval request (RunEvent + * `approval_requested` with protocol `ag-ui`, or an AG-UI interrupt + * payload) into the shared Interaction shape. The original interrupt id + * is preserved in `extensions.interrupt_id` so the 0.3.1 + * `resumeAguiInterrupt` fallback still works for servers that do not + * advertise `interaction_v1`. + */ +import type { Interaction } from '../types.js'; +import { normalizeInteraction } from './normalize.js'; + +export type AguiInterruptRequest = { + interruptId: string; + sessionId: string; + runId?: string | null; + toolCallId?: string | null; + name?: string; + message?: string; + reason?: string; + requestSchema?: Record | null; +}; + +export function interactionFromAguiInterrupt( + request: AguiInterruptRequest, +): Interaction | null { + if (!request.interruptId || !request.sessionId) return null; + return normalizeInteraction({ + interactionId: request.interruptId, + sessionId: request.sessionId, + runId: request.runId ?? null, + kind: 'approval', + title: request.name ? `确认:${request.name}` : '人工确认', + message: request.message || '本次运行需要人工确认后才能继续。', + requestSchema: request.requestSchema ?? null, + status: 'pending', + revision: 1, + source: 'agui', + extensions: { + interrupt_id: request.interruptId, + ...(request.toolCallId ? { tool_call_id: request.toolCallId } : {}), + ...(request.reason ? { reason: request.reason } : {}), + }, + }); +} diff --git a/src/core/interaction/adapters/normalize.ts b/src/core/interaction/adapters/normalize.ts new file mode 100644 index 0000000..52b01d1 --- /dev/null +++ b/src/core/interaction/adapters/normalize.ts @@ -0,0 +1,111 @@ +/** + * Shared normalization helpers for Interaction transport adapters. + * + * Every adapter produces the same Interaction shape so the store, tray, + * and submit path never branch on the transport protocol. + */ +import type { + Interaction, + InteractionKind, + InteractionOutcome, + InteractionSource, + InteractionStatus, +} from './types.js'; + +export type RawInteractionFields = { + interactionId: string; + sessionId: string; + runId?: string | null; + kind?: unknown; + title?: unknown; + message?: unknown; + requestSchema?: unknown; + presentation?: unknown; + status?: unknown; + revision?: unknown; + createdAt?: unknown; + expiresAt?: unknown; + resolvedAt?: unknown; + actor?: unknown; + outcome?: unknown; + responseSummary?: unknown; + extensions?: Record; + source: InteractionSource; +}; + +const VALID_KINDS: ReadonlySet = new Set([ + 'approval', + 'structured_input', + 'plan_review', + 'custom', +]); + +const VALID_STATUSES: ReadonlySet = new Set([ + 'pending', + 'resolving', + 'failed', + 'resolved', + 'cancelled', + 'expired', +]); + +const VALID_OUTCOMES: ReadonlySet = new Set([ + 'approved', + 'rejected', + 'submitted', + 'cancelled', + 'expired', +]); + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +export function normalizeInteraction(fields: RawInteractionFields): Interaction { + const kind = String(fields.kind || 'approval'); + const status = String(fields.status || 'pending'); + const outcome = String(fields.outcome || ''); + const presentation = asRecord(fields.presentation); + const a2ui = presentation ? asRecord(presentation.a2ui) : null; + + return { + interactionId: fields.interactionId, + sessionId: fields.sessionId, + runId: stringOrNull(fields.runId), + kind: (VALID_KINDS.has(kind) ? kind : 'custom') as InteractionKind, + title: typeof fields.title === 'string' && fields.title ? fields.title : '人工确认', + message: typeof fields.message === 'string' && fields.message + ? fields.message + : '本次运行需要人工处理后才能继续。', + requestSchema: asRecord(fields.requestSchema), + presentation: a2ui + ? { + a2ui: { + wireVersion: String(a2ui.wire_version ?? a2ui.wireVersion ?? ''), + catalogDigest: String(a2ui.catalog_digest ?? a2ui.catalogDigest ?? ''), + messages: Array.isArray(a2ui.messages) + ? (a2ui.messages.filter((entry) => typeof entry === 'object') as Array>) + : [], + }, + } + : null, + status: (VALID_STATUSES.has(status) ? status : 'pending') as InteractionStatus, + revision: Number.isFinite(Number(fields.revision)) && Number(fields.revision) > 0 + ? Number(fields.revision) + : 1, + createdAt: stringOrNull(fields.createdAt) || new Date().toISOString(), + expiresAt: stringOrNull(fields.expiresAt), + resolvedAt: stringOrNull(fields.resolvedAt), + actor: stringOrNull(fields.actor), + outcome: (VALID_OUTCOMES.has(outcome) ? outcome : null) as InteractionOutcome | null, + responseSummary: stringOrNull(fields.responseSummary), + source: fields.source, + extensions: fields.extensions || {}, + }; +} diff --git a/src/core/interaction/adapters/responses.ts b/src/core/interaction/adapters/responses.ts new file mode 100644 index 0000000..4a69ec5 --- /dev/null +++ b/src/core/interaction/adapters/responses.ts @@ -0,0 +1,56 @@ +/** + * Responses transport adapter (`/v1/responses` official semantics). + * + * Normalizes the official `mcp_approval_request` item (delivered either + * as a stream action or as a SessionEvent record) into the shared + * Interaction shape. Responses-driven submissions keep the official + * `mcp_approval_response` path — the adapter marks the record + * `source: "responses"` so the submit path can route accordingly without + * components ever branching on protocol. + */ +import type { Interaction } from '../types.js'; +import { normalizeInteraction } from './normalize.js'; + +export type ResponsesApprovalRequest = { + approvalRequestId: string; + sessionId: string; + runId?: string | null; + name?: string; + message?: string; + approvalLevel?: string; + args?: string; + requestSchema?: Record | null; +}; + +export function interactionFromResponsesApproval( + request: ResponsesApprovalRequest, +): Interaction | null { + if (!request.approvalRequestId || !request.sessionId) return null; + return normalizeInteraction({ + interactionId: request.approvalRequestId, + sessionId: request.sessionId, + runId: request.runId ?? null, + kind: 'approval', + title: request.name ? `审批:${request.name}` : '人工确认', + message: request.message || '本次运行需要人工审批后才能继续。', + requestSchema: request.requestSchema ?? null, + status: 'pending', + revision: 1, + source: 'responses', + extensions: request.approvalLevel + ? { approval_level: request.approvalLevel } + : {}, + }); +} + +/** Build the official Responses `mcp_approval_response` input item. */ +export function buildMcpApprovalResponse( + approvalRequestId: string, + approve: boolean, +): Record { + return { + type: 'mcp_approval_response', + approval_request_id: approvalRequestId, + approve, + }; +} diff --git a/src/core/interaction/adapters/session-events.ts b/src/core/interaction/adapters/session-events.ts new file mode 100644 index 0000000..2134019 --- /dev/null +++ b/src/core/interaction/adapters/session-events.ts @@ -0,0 +1,140 @@ +/** + * AgentEngine Interaction/v1 SessionEvent adapter. + * + * `SessionEventEnvelope.payload` carries the public Interaction facts: + * event_type `interaction.requested` / `ksadk.interaction/v1.requested` + * creates a pending Interaction; `interaction.resolved` (outcome in + * payload) closes it. Rejection is `resolved.outcome="rejected"`, not a + * fifth terminal event type. + */ +import type { Interaction } from '../types.js'; +import { normalizeInteraction } from './normalize.js'; + +const REQUESTED_EVENT_TYPES = new Set([ + 'interaction.requested', + 'interaction_requested', + 'ksadk.interaction/v1.requested', + 'InteractionRequested', +]); + +const RESOLVED_EVENT_TYPES = new Set([ + 'interaction.resolved', + 'interaction_resolved', + 'ksadk.interaction/v1.resolved', + 'InteractionResolved', + 'interaction.cancelled', + 'interaction.cancel', + 'interaction.expired', +]); + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Normalize an Interaction/v1 SessionEvent payload (or envelope) into the + * shared Interaction shape. Returns null when the event is not an + * interaction event. + */ +export function interactionFromSessionEvent( + raw: unknown, + fallbackSessionId?: string, +): Interaction | null { + const envelope = asRecord(raw); + if (!envelope) return null; + const eventType = String( + envelope.event_type || envelope.EventType || '', + ).trim(); + if (!eventType) return null; + + const payload = asRecord(envelope.payload ?? envelope.Content) || {}; + const body = + asRecord(payload.interaction) || + asRecord(payload.Interaction) || + asRecord(payload.interaction_request) || + payload; + const interactionId = String( + body.interaction_id || + body.InteractionId || + payload.interaction_id || + payload.InteractionId || + '', + ); + if (!interactionId) return null; + + const sessionId = String( + envelope.session_id || body.session_id || fallbackSessionId || '', + ); + if (!sessionId) return null; + + if (REQUESTED_EVENT_TYPES.has(eventType)) { + return normalizeInteraction({ + interactionId, + sessionId, + runId: body.run_id ?? envelope.run_id, + kind: body.kind ?? 'approval', + title: body.title, + message: body.message ?? body.description, + requestSchema: body.request_schema ?? body.RequestSchema, + presentation: body.presentation, + status: 'pending', + revision: body.revision ?? 1, + createdAt: body.created_at ?? envelope.timestamp, + expiresAt: body.expires_at, + source: 'interaction_v1', + extensions: body.extensions, + }); + } + + if (RESOLVED_EVENT_TYPES.has(eventType)) { + const normalizedEventStatus = + eventType === 'interaction.cancelled' || eventType === 'interaction.cancel' + ? 'cancelled' + : eventType === 'interaction.expired' + ? 'expired' + : 'resolved'; + const rawOutcome = String( + payload.outcome ?? body.outcome ?? body.status ?? '', + ).toLowerCase(); + const action = String(payload.action ?? body.action ?? '').toLowerCase(); + let outcome = rawOutcome; + if (!outcome) { + if (action === 'approve') outcome = 'approved'; + else if (action === 'reject') outcome = 'rejected'; + else if (action === 'submit') outcome = 'submitted'; + else if (action === 'cancel' || normalizedEventStatus === 'cancelled') outcome = 'cancelled'; + else if (normalizedEventStatus === 'expired') outcome = 'expired'; + else outcome = 'submitted'; + } + const status = normalizedEventStatus === 'resolved' + ? 'resolved' + : normalizedEventStatus; + return normalizeInteraction({ + interactionId, + sessionId, + runId: body.run_id ?? envelope.run_id, + kind: body.kind, + title: body.title, + message: body.message, + requestSchema: body.request_schema, + presentation: body.presentation, + status, + revision: body.revision, + createdAt: body.created_at ?? envelope.timestamp, + expiresAt: body.expires_at, + resolvedAt: payload.resolved_at ?? body.resolved_at ?? envelope.timestamp, + actor: payload.actor ?? body.actor ?? envelope.actor_ref, + outcome, + responseSummary: + payload.response_summary ?? + body.response_summary ?? + (action ? `${action}` : undefined), + source: 'interaction_v1', + extensions: body.extensions, + }); + } + + return null; +} diff --git a/src/core/interaction/client.ts b/src/core/interaction/client.ts new file mode 100644 index 0000000..3f8b730 --- /dev/null +++ b/src/core/interaction/client.ts @@ -0,0 +1,267 @@ +/** + * Headless InteractionClient implementation. + * + * One submit path per record source: + * - `interaction_v1` (AgentEngine Interaction/v1): every action is one + * `POST /agentengine/api/v1/SubmitInteraction` call with revision CAS + * and an idempotency key. + * - `responses`: official `/v1/responses` `mcp_approval_response` path + * (0.3.1 fallback for servers without `interaction_v1`). + * - `agui`: `resumeAguiInterrupt` (0.3.1 fallback). + * + * Components never branch on the source — they call `respond()`. + */ +import { + interactionIdempotencyKey, + summarizeResponse, + type Interaction, + type InteractionAction, + type InteractionClient, + type InteractionEvent, + type InteractionOutcome, + type InteractionReceipt, + type InteractionSubmitInput, +} from './types.js'; +import { InteractionStore } from './store.js'; +import { decodeReceipt } from '../../types/agent-control.js'; + +export type SubmitInteractionTransport = (params: { + AgentId: string; + SessionId: string; + RunId: string; + InteractionId: string; + ExpectedRevision: number; + Action: InteractionAction; + Response: Record; + IdempotencyKey: string; +}) => Promise; + +export type InteractionClientDeps = { + agentId: string; + store?: InteractionStore; + submitInteraction: SubmitInteractionTransport; + /** + * When bootstrap advertises `interaction_v1`, every action goes through + * SubmitInteraction regardless of the transport that produced the + * pending record. + */ + interactionV1Enabled?: boolean; + /** @deprecated 0.3.1 Responses `mcp_approval_response` fallback. */ + legacyResponsesApproval?: (interactionId: string, approve: boolean) => void; + /** @deprecated 0.3.1 AG-UI `resumeAguiInterrupt` fallback. */ + legacyAguiResume?: ( + interruptId: string, + status: 'resolved' | 'cancelled', + payload?: unknown, + ) => boolean; +}; + +const ACTION_OUTCOME: Record = { + approve: 'approved', + reject: 'rejected', + submit: 'submitted', + cancel: 'cancelled', +}; + +function syntheticReceipt( + status: InteractionReceipt['status'], + interactionId: string, +): InteractionReceipt { + return { + schema_version: 1, + command_id: `local-${interactionId}`, + status, + message_id: null, + run_id: null, + accepted_seq: null, + error: null, + extensions: {}, + }; +} + +export class InteractionClientImpl implements InteractionClient { + readonly store: InteractionStore; + private deps: InteractionClientDeps; + /** interactionId -> in-flight idempotency key (double-submit guard). */ + private inFlight = new Map(); + + constructor(deps: InteractionClientDeps) { + this.deps = deps; + this.store = deps.store || new InteractionStore(); + } + + listPending(sessionId: string): readonly Interaction[] { + return this.store.listPending(sessionId); + } + + /** Update capability routing after bootstrap capabilities change. */ + setInteractionV1Enabled(enabled: boolean): void { + this.deps.interactionV1Enabled = enabled; + } + + subscribe(listener: (event: InteractionEvent) => void): () => void { + return this.store.subscribe(listener); + } + + /** Normalize + persist an incoming Interaction (adapter output). */ + ingest(interaction: Interaction): void { + this.store.upsert(interaction); + } + + /** + * Restore state from replayed history. Only upserts server facts — + * never issues a submit — so a refresh/replay restores a pending + * Interaction without creating a second request. + */ + ingestHistory(interactions: readonly Interaction[]): void { + for (const interaction of interactions) { + this.store.upsert(interaction); + } + // Any in-flight submit whose interaction is now terminal server-side + // (another tab won first) is dropped. + for (const [interactionId] of this.inFlight) { + const record = this.findByInteractionId(interactionId); + if (record && record.status !== 'pending' && record.status !== 'resolving') { + this.inFlight.delete(interactionId); + } + } + } + + private findByInteractionId(interactionId: string): Interaction | null { + return ( + this.store.all().find( + (interaction) => interaction.interactionId === interactionId, + ) || null + ); + } + + async respond(input: InteractionSubmitInput): Promise { + const record = this.findByInteractionId(input.interactionId); + if (!record) { + return syntheticReceipt('rejected', input.interactionId); + } + + // Terminal (resolved by an authoritative SessionEvent): no second + // request leaves the client. `resolving` and `failed` stay + // submittable — a receipt is never a terminal fact. + if (record.status === 'resolved' || record.status === 'cancelled' || record.status === 'expired') { + return syntheticReceipt('duplicate', input.interactionId); + } + + // A previous submit's receipt was accepted but its terminal + // SessionEvent has not arrived yet: treat as duplicate rather than + // issuing a competing second decision. + if (record.status === 'resolving') { + return syntheticReceipt('duplicate', input.interactionId); + } + + const idempotencyKey = input.idempotencyKey + || interactionIdempotencyKey(input.interactionId, input.expectedRevision); + + // Double-click / concurrent submit guard: same idempotency key in + // flight means this exact submit already happened. + const inFlightKey = this.inFlight.get(input.interactionId); + if (inFlightKey) { + if (inFlightKey === idempotencyKey) { + return syntheticReceipt('duplicate', input.interactionId); + } + return syntheticReceipt('rejected', input.interactionId); + } + + this.inFlight.set(input.interactionId, idempotencyKey); + this.store.markResolving(record.sessionId, input.interactionId); + + const useInteractionV1 = + this.deps.interactionV1Enabled || record.source === 'interaction_v1'; + + try { + if (!useInteractionV1 && record.source === 'responses' && this.deps.legacyResponsesApproval) { + this.deps.legacyResponsesApproval( + input.interactionId, + input.action === 'approve', + ); + this.resolveLocal(record, input, idempotencyKey); + return syntheticReceipt('accepted', input.interactionId); + } + + if (!useInteractionV1 && record.source === 'agui' && this.deps.legacyAguiResume) { + const accepted = this.deps.legacyAguiResume( + String(record.extensions.interrupt_id || input.interactionId), + input.action === 'cancel' ? 'cancelled' : 'resolved', + input.response, + ); + if (!accepted) { + this.store.revertToPending(record.sessionId, input.interactionId); + return syntheticReceipt('rejected', input.interactionId); + } + this.resolveLocal(record, input, idempotencyKey); + return syntheticReceipt('accepted', input.interactionId); + } + + const raw = await this.deps.submitInteraction({ + AgentId: this.deps.agentId, + SessionId: record.sessionId, + RunId: record.runId || '', + InteractionId: input.interactionId, + ExpectedRevision: input.expectedRevision, + Action: input.action, + Response: input.response, + IdempotencyKey: idempotencyKey, + }); + const receipt = decodeReceipt(raw); + + if (receipt.status === 'accepted' || receipt.status === 'duplicate') { + // The receipt only proves the command entered the durable Inbox — + // it is NOT a terminal fact. Keep `resolving` until the + // authoritative interaction.resolved/cancelled/expired + // SessionEvent arrives via the dispatcher or replay. Record the + // idempotency key for replay correlation. + this.store.recordIdempotencyKey( + record.sessionId, + input.interactionId, + idempotencyKey, + ); + } else if (receipt.status === 'queue_full') { + // Transient: back to pending so the user can retry. + this.store.revertToPending(record.sessionId, input.interactionId); + } else { + // Definitive rejection (e.g. interaction_already_resolved from a + // first-wins other tab), unsupported, or uncertain: surface the + // failure. The SessionEvent stream remains the only source of a + // terminal state. + this.store.markFailed(record.sessionId, input.interactionId, { + code: receipt.error?.code ?? String(receipt.status), + message: receipt.error?.message ?? `提交未通过(${receipt.status})`, + retryable: receipt.error?.retryable ?? false, + }); + } + return receipt; + } finally { + this.inFlight.delete(input.interactionId); + } + } + + private resolveLocal( + record: Interaction, + input: InteractionSubmitInput, + idempotencyKey: string, + ): void { + const terminalStatus = + input.action === 'cancel' ? 'cancelled' : 'resolved'; + this.store.resolveLocally(record.sessionId, input.interactionId, { + status: terminalStatus, + outcome: ACTION_OUTCOME[input.action], + actor: 'user', + responseSummary: summarizeResponse(input.action, input.response), + revision: input.expectedRevision, + }); + // Keep the idempotency key recorded for replay correlation. + const updated = this.store.get(record.sessionId, input.interactionId); + if (updated) { + this.store.upsert({ + ...updated, + extensions: { ...updated.extensions, idempotency_key: idempotencyKey }, + }); + } + } +} diff --git a/src/core/interaction/index.ts b/src/core/interaction/index.ts new file mode 100644 index 0000000..7f1f6dd --- /dev/null +++ b/src/core/interaction/index.ts @@ -0,0 +1,103 @@ +/** + * Interaction/v1 headless module. + * + * One Interaction store, one submit path, three transport adapters + * (Interaction/v1 SessionEvents, Responses `mcp_approval_request`, + * AG-UI interrupts). Components consume `InteractionClient` / + * `useInteractions` and never branch on the approval protocol. + */ +export type { + A2uiPresentation, + Interaction, + InteractionAction, + InteractionClient, + InteractionEvent, + InteractionKind, + InteractionOutcome, + InteractionPresentation, + InteractionReceipt, + InteractionSource, + InteractionStatus, + InteractionSubmitInput, +} from './types.js'; +export { + interactionIdempotencyKey, + isTerminalInteraction, + summarizeResponse, +} from './types.js'; +export { InteractionStore } from './store.js'; +export { InteractionClientImpl } from './client.js'; +export type { InteractionClientDeps, SubmitInteractionTransport } from './client.js'; +export { interactionFromSessionEvent } from './adapters/session-events.js'; +export { + buildMcpApprovalResponse, + interactionFromResponsesApproval, +} from './adapters/responses.js'; +export type { ResponsesApprovalRequest } from './adapters/responses.js'; +export { interactionFromAguiInterrupt } from './adapters/agui.js'; +export type { AguiInterruptRequest } from './adapters/agui.js'; +export { + A2UI_WIRE_VERSION, + computeA2uiCatalogDigest, + validateA2uiPresentation, +} from './a2ui-validate.js'; +export type { A2uiRenderMode } from './a2ui-validate.js'; + +import { InteractionStore } from './store.js'; +import { interactionFromSessionEvent } from './adapters/session-events.js'; +import { interactionFromResponsesApproval } from './adapters/responses.js'; +import { interactionFromAguiInterrupt } from './adapters/agui.js'; +import type { Interaction } from './types.js'; + +/** + * App-wide shared store. Transport ingestion points (run dispatcher, + * session lifecycle) write here; the app's `InteractionClient` reads and + * submits through the same store. + */ +export const sharedInteractionStore = new InteractionStore(); + +/** Ingest an Interaction/v1 (or legacy) SessionEvent record/envelope. */ +export function ingestSessionEventRecord( + raw: unknown, + fallbackSessionId?: string, +): Interaction | null { + const interaction = interactionFromSessionEvent(raw, fallbackSessionId); + if (interaction) { + sharedInteractionStore.upsert(interaction); + } + return interaction; +} + +/** Ingest a RunEngine `approval_requested` event (Responses or AG-UI). */ +export function ingestApprovalRequestedEvent(event: { + approvalRequestId: string; + protocol: 'ag-ui' | 'responses'; + name?: string; + message?: string; + args?: string; + approvalLevel?: string; + sessionId?: string | null; +}): Interaction | null { + const sessionId = event.sessionId || ''; + if (!sessionId || !event.approvalRequestId) return null; + const interaction = + event.protocol === 'ag-ui' + ? interactionFromAguiInterrupt({ + interruptId: event.approvalRequestId, + sessionId, + name: event.name, + message: event.message, + reason: event.approvalLevel, + }) + : interactionFromResponsesApproval({ + approvalRequestId: event.approvalRequestId, + sessionId, + name: event.name, + message: event.message, + approvalLevel: event.approvalLevel, + }); + if (interaction) { + sharedInteractionStore.upsert(interaction); + } + return interaction; +} diff --git a/src/core/interaction/store.ts b/src/core/interaction/store.ts new file mode 100644 index 0000000..2662509 Binary files /dev/null and b/src/core/interaction/store.ts differ diff --git a/src/core/interaction/types.ts b/src/core/interaction/types.ts new file mode 100644 index 0000000..af959ff --- /dev/null +++ b/src/core/interaction/types.ts @@ -0,0 +1,153 @@ +/** + * Interaction/v1 client-side types. + * + * Mirrors the canonical `contracts/agent-kernel/v1/interaction.schema.json`: + * one durable Interaction identity per human decision, revision-based + * optimistic concurrency, and first-wins terminal semantics. Responses + * approval requests and AG-UI interrupts are transport adapters that + * normalize into this single shape — components never branch on + * `approvalProtocol`. + */ +import type { AgentControlReceipt } from '../../types/agent-control.js'; + +export type InteractionStatus = + | 'pending' + | 'resolving' + | 'failed' + | 'resolved' + | 'cancelled' + | 'expired'; + +export type InteractionAction = 'approve' | 'reject' | 'submit' | 'cancel'; + +export type InteractionKind = + | 'approval' + | 'structured_input' + | 'plan_review' + | 'custom'; + +/** Which transport surface produced the normalized Interaction. */ +export type InteractionSource = 'interaction_v1' | 'responses' | 'agui'; + +export type A2uiPresentation = { + /** Production wire is locked to "0.9.1". */ + wireVersion: string; + /** Pinned catalog digest required before rendering A2UI. */ + catalogDigest: string; + messages: Array>; +}; + +export type InteractionPresentation = { + a2ui?: A2uiPresentation; +}; + +export type InteractionOutcome = + | 'approved' + | 'rejected' + | 'submitted' + | 'cancelled' + | 'expired'; + +/** + * The single normalized Interaction record. Internal provider fields + * (native targets, checkpoints, permits) never appear here — the server + * redacts them before emitting public events. + */ +export type Interaction = { + interactionId: string; + sessionId: string; + runId: string | null; + kind: InteractionKind; + title: string; + message: string; + requestSchema: Record | null; + presentation: InteractionPresentation | null; + status: InteractionStatus; + revision: number; + createdAt: string; + expiresAt: string | null; + resolvedAt: string | null; + actor: string | null; + outcome: InteractionOutcome | null; + /** Redacted summary of the submitted response; never raw secrets. */ + responseSummary: string | null; + source: InteractionSource; + extensions: Record; +}; + +export type InteractionEvent = + | { type: 'interaction_requested'; interaction: Interaction } + | { type: 'interaction_updated'; interaction: Interaction } + | { type: 'interaction_resolved'; interaction: Interaction } + | { type: 'interaction_removed'; interactionId: string }; + +export type InteractionSubmitInput = { + interactionId: string; + expectedRevision: number; + action: InteractionAction; + response: Record; + idempotencyKey: string; +}; + +export type InteractionReceipt = AgentControlReceipt; + +/** + * Headless public API. Transport-agnostic: every AgentEngine + * Interaction/v1 action goes through one `respond` call. + */ +export interface InteractionClient { + listPending(sessionId: string): readonly Interaction[]; + subscribe(listener: (event: InteractionEvent) => void): () => void; + respond(input: InteractionSubmitInput): Promise; +} + +/** Statuses that may only be set by an authoritative terminal SessionEvent. */ +export const TERMINAL_INTERACTION_STATUSES: ReadonlySet = + new Set(['resolved', 'cancelled', 'expired']); + +export function isTerminalInteraction(status: InteractionStatus): boolean { + return TERMINAL_INTERACTION_STATUSES.has(status); +} + +/** Default idempotency key derivation: stable per (id, revision). */ +export function interactionIdempotencyKey( + interactionId: string, + expectedRevision: number, +): string { + return `interaction:${interactionId}:revision-${expectedRevision}`; +} + +/** Redact a response payload into a short human-readable summary. */ +export function summarizeResponse( + action: InteractionAction, + response: Record, +): string { + const approved = response.approved; + if (typeof approved === 'boolean') { + return approved ? '已同意' : '已拒绝'; + } + const keys = Object.keys(response); + if (keys.length === 0) { + return action === 'approve' + ? '已同意' + : action === 'reject' + ? '已拒绝' + : action === 'cancel' + ? '已取消' + : '已提交'; + } + const preview = keys + .slice(0, 3) + .map((key) => { + const value = response[key]; + const text = + typeof value === 'string' + ? value.length > 24 ? `${value.slice(0, 24)}…` : value + : typeof value === 'object' && value !== null + ? '[object]' + : String(value); + return `${key}=${text}`; + }) + .join(', '); + return `${action}(${preview})`; +} diff --git a/src/core/run/dispatcher.ts b/src/core/run/dispatcher.ts index b3697a3..aff80df 100644 --- a/src/core/run/dispatcher.ts +++ b/src/core/run/dispatcher.ts @@ -7,6 +7,10 @@ import { upsertToolBlock, } from './blocks.js'; import { useMessageStore } from '../../stores/message.js'; +import { + ingestApprovalRequestedEvent, + ingestSessionEventRecord, +} from '../interaction/index.js'; import { useStreamingStore } from '../../stores/streaming.js'; import { useSessionStore } from '../../stores/session.js'; import { useCheckpointStore } from '../../stores/checkpoint.js'; @@ -233,6 +237,15 @@ export function dispatchRunEventToStores(event: RunEvent) { case 'approval_requested': { ensureAssistantMessage(event.messageId); + ingestApprovalRequestedEvent({ + approvalRequestId: event.approvalRequestId, + protocol: event.protocol, + name: event.name, + message: event.message, + args: event.args, + approvalLevel: event.approvalLevel, + sessionId: event.sessionId, + }); ms.patchMessages((prev) => prev.map((msg) => { if (msg.id !== event.messageId) return msg; @@ -442,6 +455,7 @@ export function dispatchRunEventToStores(event: RunEvent) { case 'stream_event': { const streamSessionId = event.sessionId || event.event.SessionId; + ingestSessionEventRecord(event.event, streamSessionId || undefined); // 记录最后事件 seq,供网络断线后 afterSeqId 续订重连。 const evtSeq = (event.event as { SeqId?: number }).SeqId; if (typeof evtSeq === 'number' && evtSeq > 0) { diff --git a/src/core/run/engine.ts b/src/core/run/engine.ts index a5a99f6..c0d961c 100644 --- a/src/core/run/engine.ts +++ b/src/core/run/engine.ts @@ -1,4 +1,13 @@ -import type { RunEngine, RunStage, RunEvent, RunEngineConfig } from './types.js'; +import type { + RunControlState, + RunEngine, + RunStage, + RunEvent, + RunEngineConfig, + RuntimeExecutionMode, + SubmitControlCommand, +} from './types.js'; +import { ContractMismatchError, decodeReceipt } from '../../types/agent-control.js'; import type { ApiFacade } from '../api/types.js'; import type { StreamAction } from '../stream/types.js'; import { createProtocol } from '../stream/index.js'; @@ -10,14 +19,12 @@ import { ApiError } from '../../api/errors.js'; import { buildModelOptionsFromThinkingMode, normalizeThinkingMode } from '../../utils/model-options.js'; import { resolveRunAgentApiFormat } from '../../utils/layout-constants.js'; import { useStreamingStore } from '../../stores/streaming.js'; +import { KernelRunEventTranslator, peekKernelReceipt } from '../stream/kernel-events.js'; import type { StreamProtocol } from '../stream/types.js'; import type { RuntimeApiFormat } from '../../types/api.js'; import { AguiRunClient } from './agui.js'; -type StreamConsumeResult = { - receivedData: boolean; - terminalStatus?: string; -}; +type StreamConsumeResult = { terminalStatus?: string }; const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'error', 'cancelled', 'canceled', 'aborted', 'interrupted', 'resume_failed']); @@ -99,6 +106,8 @@ export class RunEngineImpl implements RunEngine { private aguiClient: AguiRunClient | null = null; private aguiThreadSessionId: string | null = null; private api: ApiFacade; + private controlStateValue: RunControlState = { phase: 'idle' }; + private lastControlCommand: SubmitControlCommand | null = null; private config: RunEngineConfig = { agentId: 'default-agent', apiFormats: ['responses'], @@ -113,6 +122,79 @@ export class RunEngineImpl implements RunEngine { get stage() { return this._stage; } + get controlState(): RunControlState { return this.controlStateValue; } + + /** + * Submit an agent-kernel/v1 control command. Receipt semantics: + * accepted/duplicate -> queued (never treated as completion), queue_full -> + * retryable with the same idempotency key, unsupported -> surfaces the + * capability reason, persistence_uncertain -> confirm status before retry. + */ + async submitControl(command: SubmitControlCommand): Promise { + this.lastControlCommand = command; + let receipt; + try { + receipt = decodeReceipt(await this.api.submitControl(command)); + } catch (error) { + if (error instanceof ContractMismatchError) { + this.controlStateValue = { phase: 'contract_mismatch', error: null, receipt: null }; + this.emit({ type: 'system_message', content: '【系统提示】控制指令响应不符合 agent-kernel/v1 合同,已停止变更本地状态。' }); + return this.controlStateValue; + } + throw error; + } + this.controlStateValue = this.controlStateFor(receipt, command); + if (receipt.status === 'accepted' || receipt.status === 'duplicate') { + this.emit({ type: 'activity', phase: '指令已入队,等待运行时处理', status: 'waiting', countEvent: false }); + } else if (receipt.status === 'queue_full') { + this.emit({ type: 'system_message', content: '【系统提示】指令队列已满,可点击重试;重试将复用同一幂等键。' }); + } else if (receipt.status === 'unsupported') { + this.emit({ type: 'system_message', content: `【系统提示】运行时不支持该操作:${receipt.error?.message || ''}` }); + } else if (receipt.status === 'persistence_uncertain') { + this.emit({ type: 'system_message', content: '【系统提示】指令提交结果待确认,正在查询状态…' }); + } else if (receipt.status === 'rejected') { + this.emit({ type: 'system_message', content: `【系统提示】指令被拒绝:${receipt.error?.message || ''}` }); + } + return this.controlStateValue; + } + + private controlStateFor( + receipt: ReturnType, + command: SubmitControlCommand, + ): RunControlState { + switch (receipt.status) { + case 'accepted': + case 'duplicate': + return { phase: 'queued', receipt, error: null }; + case 'queue_full': + return { phase: 'retryable', retryKey: command.idempotency_key, receipt, error: receipt.error }; + case 'unsupported': + return { phase: 'unsupported', receipt, error: receipt.error }; + case 'persistence_uncertain': + return { phase: 'confirming', retryKey: command.idempotency_key, receipt, error: receipt.error }; + case 'rejected': + default: + return { phase: 'rejected', receipt, error: receipt.error }; + } + } + + /** Retry the last control command, reusing its idempotency key. */ + async retryControl(): Promise { + const previous = this.lastControlCommand; + if (!previous || (this.controlStateValue.phase !== 'retryable' && this.controlStateValue.phase !== 'confirming')) { + return this.controlStateValue; + } + if (this.controlStateValue.phase === 'confirming') { + // persistence_uncertain: confirm via status query before deciding to retry. + try { + await this.api.getAgentStatus(); + } catch (error) { + console.warn('[RunEngine] status query after persistence_uncertain failed:', error); + } + } + return this.submitControl(previous); + } + updateConfig(config: RunEngineConfig): void { this.config = { ...config, @@ -163,6 +245,7 @@ export class RunEngineImpl implements RunEngine { attachments: File[]; responsesInput?: unknown; previousResponseId?: string; + executionMode?: RuntimeExecutionMode; sessionId?: string | null; onSessionCreated?: (sessionId: string) => void; onSessionUpsert?: (sessionId: string) => void; @@ -176,8 +259,6 @@ export class RunEngineImpl implements RunEngine { (async () => { let sessionId: string | null = draft.sessionId || null; try { - let retriedWithNewSession = false; - if (!sessionId) { sessionId = await this.createSession(draft); } @@ -205,6 +286,7 @@ export class RunEngineImpl implements RunEngine { const hostedTransport = this.config.hostedChatTransport; const useAgui = !isResponsesResume + && !draft.executionMode && draft.attachments.length === 0 && hostedTransport?.Protocol === 'ag-ui'; const aguiClient = useAgui ? this.getAguiClient(sessionId) : null; @@ -247,23 +329,19 @@ export class RunEngineImpl implements RunEngine { const assistantMessageId = `msg-${Date.now()}`; - const streamResult = await this.consumeStream(stream, protocol, protocolState, assistantMessageId, invocationId); - - if (!streamResult.receivedData && !draft.sessionId && !retriedWithNewSession) { - retriedWithNewSession = true; - sessionId = await this.createSession(draft); - if (sessionId) { - this.activeSessionId = sessionId; - body.SessionId = sessionId; - this.setStage('connecting'); - this.emit({ type: 'activity', phase: '重建会话后重新连接', status: 'connecting', countEvent: false }); - const retryStream = await this.api.runAgent(body, { signal: this.abortController?.signal }); - this.setStage('streaming'); - this.emit({ type: 'activity', phase: '等待首个输出', status: 'waiting', countEvent: false }); - const retryMsgId = `msg-${Date.now()}`; - const retryResult = await this.consumeStream(retryStream, protocol, protocolState, retryMsgId, invocationId); - streamResult.terminalStatus = retryResult.terminalStatus; - } + // AgentKernel path: RunAgent answers with a control receipt (JSON); + // run output arrives on SubscribeSessionEvents as raw kernel frames. + const peeked = await peekKernelReceipt(stream); + let streamResult: StreamConsumeResult; + if (peeked.receipt) { + streamResult = await this.consumeKernelRunEvents( + sessionId, + invocationId, + peeked.receipt, + assistantMessageId, + ); + } else { + streamResult = await this.consumeStream(peeked.stream, protocol, protocolState, assistantMessageId, invocationId); } if (streamResult.terminalStatus === 'cancelled') { @@ -730,10 +808,16 @@ export class RunEngineImpl implements RunEngine { sessionId: string, apiFormat: RuntimeApiFormat, isResponsesResume: boolean, - draft: { text: string; responsesInput?: unknown; previousResponseId?: string }, + draft: { + text: string; + responsesInput?: unknown; + previousResponseId?: string; + executionMode?: RuntimeExecutionMode; + }, fileParts: Array>, invocationId: string, ): Record { + const executionMetadata = this.buildExecutionMetadata(draft); const body: Record = { AgentId: this.config.agentId, SessionId: sessionId, @@ -748,6 +832,7 @@ export class RunEngineImpl implements RunEngine { Metadata: { agentengine: { tool_approval_mode: this.config.permissionMode || 'risk', + ...executionMetadata, }, }, }; @@ -766,6 +851,128 @@ export class RunEngineImpl implements RunEngine { return body; } + private buildExecutionMetadata( + draft: { text: string; executionMode?: RuntimeExecutionMode }, + ): Record { + const mode = draft.executionMode; + if (!mode) return {}; + const capability = this.config.runtimeCapabilityMatrix?.[mode]; + if (!capability?.supported || capability.mode === 'unavailable') return {}; + if (mode === 'plan') return { collaboration_mode: 'plan' }; + if (mode === 'goal') { + const objective = draft.text.trim(); + return objective + ? { collaboration_mode: 'default', goal_objective: objective } + : {}; + } + return {}; + } + + /** + * Consume an AgentKernel run: the receipt proved the durable enqueue; the + * run output arrives on SubscribeSessionEvents as raw kernel frames which + * are translated to legacy SessionEventRecords (dispatcher handles the + * message merge + interaction ingestion). Reconnects resume from the last + * observed kernel seq. + */ + private async consumeKernelRunEvents( + sessionId: string, + invocationId: string, + receipt: import('../stream/kernel-events.js').KernelRunReceipt, + assistantMessageId: string, + ): Promise { + if (receipt.status === 'rejected' || receipt.status === 'unsupported') { + this.setStage('error'); + this.emit({ + type: 'error', + error: new Error(receipt.error?.message || `运行提交被拒绝(${receipt.status})`), + }); + return { terminalStatus: 'failed' }; + } + if (receipt.status === 'queue_full') { + this.setStage('error'); + this.emit({ type: 'rate_limited', message: '运行队列已满,请稍后重试' }); + return { terminalStatus: 'failed' }; + } + // accepted / duplicate / persistence_uncertain: the run is (very likely) + // durable on the kernel; watch the event stream for its outcome. + if (receipt.status === 'persistence_uncertain') { + this.emit({ + type: 'system_message', + content: '提交结果未知,正在从事件流确认运行状态…', + }); + } + + this.emit({ type: 'assistant_message_created', messageId: assistantMessageId, invocationId }); + this.emit({ type: 'activity', phase: '已提交运行,订阅事件流', status: 'running', countEvent: false }); + + const translator = new KernelRunEventTranslator(sessionId); + let terminalStatus: string | undefined; + let consecutiveErrors = 0; + const maxConsecutiveErrors = 6; + + while (terminalStatus === undefined && consecutiveErrors < maxConsecutiveErrors) { + try { + const afterSeq = useStreamingStore.getState().lastSeqId || 0; + const stream = await this.api.subscribeSessionEvents( + sessionId, + afterSeq, + { signal: this.abortController?.signal }, + ); + consecutiveErrors = 0; + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (terminalStatus === undefined) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const split = splitSseBuffer(buffer); + buffer = split.remainder; + for (const chunk of split.chunks) { + if (!chunk.trim()) continue; + for (const transportEvent of parseSseChunk(chunk)) { + const frame = transportEvent.data as Record | undefined; + if (!frame || typeof frame !== 'object') continue; + const record = translator.translate(frame); + const seq = Number(frame.seq); + if (Number.isFinite(seq) && seq > 0) { + useStreamingStore.getState().setLastSeqId(seq); + } + if (!record) continue; + this.emit({ type: 'stream_event', event: record as SessionEventRecord, sessionId }); + if (record.EventType === 'run_status') { + const status = String( + (record.Content as { status?: unknown } | undefined)?.status || '', + ).toLowerCase(); + // Kernel runs pause as `interrupted` while an interaction + // (approval) is pending; that is not terminal — keep streaming + // until completed/failed/cancelled. + if (status !== 'interrupted' && TERMINAL_RUN_STATUSES.has(status)) { + terminalStatus = status; + } + } + } + } + } + if (terminalStatus === undefined) { + // Kernel subscriptions replay the backlog and close; poll until the + // run reaches a terminal state (it may wait on an interaction). + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return { terminalStatus: 'cancelled' }; + } + consecutiveErrors += 1; + if (consecutiveErrors >= maxConsecutiveErrors) break; + await new Promise((resolve) => setTimeout(resolve, 800 * consecutiveErrors)); + } + } + if (terminalStatus === undefined) terminalStatus = 'interrupted'; + return { terminalStatus }; + } + private async consumeStream( stream: ReadableStream, protocol: StreamProtocol, @@ -776,15 +983,12 @@ export class RunEngineImpl implements RunEngine { const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ''; - let receivedData = false; let messageCreated = false; try { while (true) { const { value, done } = await reader.read(); if (done) break; - receivedData = true; - if (!messageCreated) { messageCreated = true; this.emit({ type: 'assistant_message_created', messageId, invocationId }); @@ -838,7 +1042,7 @@ export class RunEngineImpl implements RunEngine { if (shouldStop) { reader.cancel().catch(() => {}); - return { receivedData, terminalStatus }; + return { terminalStatus }; } } } @@ -848,7 +1052,7 @@ export class RunEngineImpl implements RunEngine { } } - return { receivedData }; + return {}; } private isCompactionChunk(chunk: string): boolean { @@ -878,9 +1082,9 @@ export class RunEngineImpl implements RunEngine { messageId, approvalRequestId: action.approvalRequestId, protocol: 'responses', - name: '人工确认', - args: '', - message: '本次运行需要人工审批后才能继续。', + name: action.name || '人工确认', + args: action.args || '', + message: action.message || '本次运行需要人工审批后才能继续。', }); break; case 'incomplete': diff --git a/src/core/run/types.ts b/src/core/run/types.ts index 40b2246..4a5a1cb 100644 --- a/src/core/run/types.ts +++ b/src/core/run/types.ts @@ -1,6 +1,8 @@ import type { RuntimeApiFormat } from '../../types/api.js'; +import type { AgentControlError, AgentControlReceipt } from '../../types/agent-control.js'; import type { HostedChatTransport } from '../../types/api.js'; import type { ModelCatalogItem } from '../../components/chat/types.js'; +import type { RuntimeCapabilityMatrix } from '../../types/agent-control.js'; export type RunStage = | 'idle' | 'creating-session' | 'uploading-files' @@ -10,6 +12,13 @@ export type RunStage = /** Default approval policy applied to a newly started conversation run. */ export type PermissionMode = 'ask' | 'risk' | 'full'; +/** + * User-selectable execution controls. + * + * A runtime's agent loop is its internal execution machinery, not a turn mode. + * Only Plan and Goal have distinct public request semantics. + */ +export type RuntimeExecutionMode = 'plan' | 'goal'; export type RunEvent = | { type: 'stage_changed'; stage: RunStage; sessionId?: string | null } @@ -73,6 +82,7 @@ export type RunEngineConfig = { selectedModelMetadata?: ModelCatalogItem | null; thinkingMode: string; permissionMode?: PermissionMode; + runtimeCapabilityMatrix?: RuntimeCapabilityMatrix; hostedChatTransport?: HostedChatTransport; checkpointResumePreviewEnabled?: boolean; }; @@ -84,6 +94,7 @@ export interface RunEngine { attachments: File[]; responsesInput?: unknown; previousResponseId?: string; + executionMode?: RuntimeExecutionMode; sessionId?: string | null; onSessionCreated?: (sessionId: string) => void; onSessionUpsert?: (sessionId: string) => void; @@ -113,5 +124,31 @@ export interface RunEngine { onSettled?: (sessionId: string | null) => void; }): boolean; readonly stage: RunStage; + readonly controlState: RunControlState; + submitControl(command: SubmitControlCommand): Promise; + retryControl(): Promise; subscribe(listener: (event: RunEvent) => void): () => void; } + +/** + * agent-kernel/v1 control surface state. `accepted`/`duplicate` receipts move + * the run to queued — they never imply completion. + */ +export type RunControlPhase = + | 'idle' | 'queued' | 'retryable' | 'unsupported' | 'confirming' + | 'contract_mismatch' | 'rejected'; + +export type RunControlState = { + phase: RunControlPhase; + /** Idempotency key to reuse when retrying (queue_full/confirming). */ + retryKey?: string; + /** Populated for unsupported/rejected receipts. */ + error?: AgentControlError | null; + receipt?: AgentControlReceipt | null; +}; + +export type SubmitControlCommand = { + command_type: 'enqueue' | 'steer' | 'inject' | 'interrupt' | 'pause' | 'resume' | 'submit_interaction'; + idempotency_key: string; + payload: Record; +}; diff --git a/src/core/stream/index.ts b/src/core/stream/index.ts index 18ee56f..0641b14 100644 --- a/src/core/stream/index.ts +++ b/src/core/stream/index.ts @@ -11,3 +11,16 @@ export function createProtocol(apiFormat: string): StreamProtocol { } export type { StreamProtocol, StreamAction } from './types.js'; +export { + RuntimeItemReducer, + legacyAssistantIdentity, + projectRuntimeItems, + responsesEventToItemOperations, + sessionEventToItemOperation, +} from './runtime-items.js'; +export type { + RuntimeItem, + RuntimeItemOperation, + RuntimeItemProjection, + RuntimeItemSnapshot, +} from './runtime-items.js'; diff --git a/src/core/stream/kernel-events.ts b/src/core/stream/kernel-events.ts new file mode 100644 index 0000000..745b9a3 --- /dev/null +++ b/src/core/stream/kernel-events.ts @@ -0,0 +1,369 @@ +/** + * agent-kernel/v1 session event frames → legacy SessionEventRecord. + * + * RunAgent on a kernel-backed agent returns an AgentControl receipt (JSON) + * instead of a Responses SSE stream. The engine then subscribes to + * SubscribeSessionEvents (raw kernel envelopes) and translates each frame + * into the legacy session-event read model so the existing dispatcher / + * message merge / interaction ingestion paths keep working unchanged. + * + * Interaction/v1 frames are passed through verbatim: the interaction + * adapter already understands the kernel envelope shape. + */ + +export type KernelSessionEventFrame = Record; + +export type LegacySessionEventRecord = { + EventId?: string; + EventType?: string; + SessionId?: string; + InvocationId?: string; + Content?: Record; + Metadata?: Record; + SeqId?: number; + Timestamp?: string; + [key: string]: unknown; +}; + +const RUN_STATUS_BY_EVENT_TYPE: Record = { + 'run.started': 'in_progress', + 'run.progress': 'in_progress', + 'run.completed': 'completed', + 'run.failed': 'failed', + 'run.canceled': 'cancelled', + 'run.interrupted': 'interrupted', +}; + +const TERMINAL_CONTROL_STATES = new Set(['completed', 'failed', 'cancelled', 'canceled']); + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function frameNativeKind(frame: KernelSessionEventFrame): string { + const source = asRecord(frame.source); + const metadata = source ? asRecord(source.metadata) : null; + return String(metadata?.native_item_kind || ''); +} + +function frameParts(frame: KernelSessionEventFrame, key: 'initial' | 'snapshot'): unknown[] { + const holder = asRecord(frame[key]); + const parts = holder?.parts; + return Array.isArray(parts) ? parts : []; +} + +function partText(part: unknown): string { + const record = asRecord(part); + if (!record) return ''; + if (typeof record.text === 'string') return record.text; + const data = asRecord(record.data); + if (data) { + const content = data.content; + if (Array.isArray(content)) { + return content + .map((entry) => String(asRecord(entry)?.text || '')) + .join(''); + } + for (const key of ['text', 'output', 'stdout', 'stderr']) { + if (data[key] != null) return String(data[key]); + } + } + return ''; +} + +function partsText(parts: unknown[]): string { + return parts.map(partText).join(''); +} + +function looksLikeUserMessage(parts: unknown[]): boolean { + return parts.some((part) => asRecord(asRecord(part)?.data)?.type === 'userMessage'); +} + +function toolPayload(parts: unknown[]): unknown { + if (parts.length === 1) { + const data = asRecord(asRecord(parts[0])?.data); + if (data) return data; + } + const text = partsText(parts); + return text || null; +} + +/** + * Stateful translator: accumulates item.updated text deltas per part so the + * emitted assistant_stream_snapshot records are cumulative (the legacy read + * model treats snapshots as full-text snapshots, not deltas). + */ +export class KernelRunEventTranslator { + private readonly textByPart = new Map(); + private readonly sessionId: string; + + constructor(sessionId: string) { + this.sessionId = sessionId; + } + + translate(frame: KernelSessionEventFrame): LegacySessionEventRecord | null { + if (!asRecord(frame)) return null; + const seq = Number(frame.seq); + if (!Number.isFinite(seq) || seq <= 0) return null; + const family = String(frame.family || ''); + const eventType = String(frame.event_type || ''); + const runId = String(frame.run_id || ''); + + const base = (): LegacySessionEventRecord => ({ + EventId: frame.event_id ? String(frame.event_id) : `kernel-${seq}`, + SessionId: this.sessionId, + InvocationId: runId || undefined, + SeqId: seq, + }); + + if (family === 'interaction') { + // The interaction adapter reads interaction facts from + // envelope.payload; wrap the flattened kernel frame so the adapter's + // payload/body fallbacks find interaction_id / kind / outcome. + return { + ...base(), + EventType: eventType, + payload: frame, + } as LegacySessionEventRecord; + } + + if (family === 'control') { + if (eventType === 'control.run_transition') { + const state = String(frame.state || '').toLowerCase(); + if (TERMINAL_CONTROL_STATES.has(state)) { + return { ...base(), EventType: 'run_status', Content: { status: state } }; + } + } + return null; + } + + if (family !== 'runtime') return null; + + const runStatus = RUN_STATUS_BY_EVENT_TYPE[eventType]; + if (runStatus) { + return { ...base(), EventType: 'run_status', Content: { status: runStatus } }; + } + + const nativeKind = frameNativeKind(frame); + + if (eventType === 'item.started') { + const parts = frameParts(frame, 'initial'); + if (nativeKind === 'userMessage' || looksLikeUserMessage(parts)) return null; + if (nativeKind === 'agentMessage' || (!nativeKind && frame.item_kind === 'message')) { + return { + ...base(), + EventType: 'assistant_stream_snapshot', + Content: { parts: [{ text: '' }] }, + }; + } + if (nativeKind && nativeKind !== 'notification') { + return { + ...base(), + EventType: 'tool_call', + Metadata: { + call_id: String(frame.item_id || ''), + tool_name: nativeKind, + run_id: runId, + tool_args: toolPayload(parts), + }, + }; + } + return null; + } + + if (eventType === 'item.updated') { + const parts = frameParts(frame, 'snapshot'); + const part = asRecord(parts[0]); + const partId = String(part?.part_id || frame.item_id || seq); + const op = String(frame.op || 'replace'); + const delta = partsText(parts); + const current = this.textByPart.get(partId) || ''; + const next = op === 'append' ? current + delta : delta; + this.textByPart.set(partId, next); + if (nativeKind === 'agentMessage' || (!nativeKind && frame.item_kind === 'message')) { + return { + ...base(), + EventType: 'assistant_stream_snapshot', + Content: { parts: [{ text: next }] }, + }; + } + if (nativeKind === 'reasoning' || frame.item_kind === 'reasoning') { + return { + ...base(), + EventType: 'reasoning', + Content: { text: next }, + }; + } + return null; + } + + if (eventType === 'item.completed') { + const parts = frameParts(frame, 'snapshot'); + if (nativeKind === 'userMessage' || looksLikeUserMessage(parts)) { + return { + ...base(), + EventType: 'user_message', + Content: { parts: [{ text: partsText(parts) }] }, + }; + } + if (nativeKind === 'agentMessage' || (!nativeKind && frame.item_kind === 'message')) { + const text = partsText(parts); + this.textByPart.set(String(asRecord(parts[0])?.part_id || frame.item_id || seq), text); + return { + ...base(), + EventType: 'assistant_message', + Content: { parts: [{ text }] }, + }; + } + if (nativeKind === 'reasoning' || frame.item_kind === 'reasoning') { + return { ...base(), EventType: 'reasoning', Content: { text: partsText(parts) } }; + } + if (nativeKind && nativeKind !== 'notification') { + return { + ...base(), + EventType: 'tool_result', + Metadata: { + call_id: String(frame.item_id || ''), + tool_name: nativeKind, + run_id: runId, + tool_output: toolPayload(parts), + }, + }; + } + return null; + } + + return null; + } +} + +/** Parsed RunAgent receipt on the kernel control path (ActionResponse.Data). */ +export type KernelRunReceipt = { + status: string; + messageId?: string | null; + runId?: string | null; + acceptedSeq?: number | null; + error?: { code?: string; message?: string } | null; +}; + +/** + * Detect the kernel receipt JSON body on a RunAgent response stream. Returns + * the receipt, or a stream with the already-read bytes prepended when the + * body is an SSE stream (legacy runtime path). + */ +export async function peekKernelReceipt( + stream: ReadableStream, +): Promise<{ receipt: KernelRunReceipt | null; stream: ReadableStream }> { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let receipt: KernelRunReceipt | null = null; + let readDone = false; + + const looksLikeSse = (text: string): boolean => + text.includes('data:') || text.includes('event:') || text.includes(':'); + + try { + while (true) { + const { value, done } = await reader.read(); + if (value) buffer += decoder.decode(value, { stream: true }); + if (done) { + readDone = true; + break; + } + const trimmed = buffer.trimStart(); + if (trimmed.startsWith('{')) { + if (isCompleteJson(buffer)) { + receipt = parseReceipt(buffer); + break; + } + } else if (looksLikeSse(buffer)) { + break; + } + } + } catch (error) { + // Fall through: whatever was buffered is replayed on the returned stream. + void error; + } + + const prefix = new TextEncoder().encode(buffer); + const replay = new ReadableStream({ + start(controller) { + if (prefix.length > 0) controller.enqueue(prefix); + if (readDone || receipt) { + controller.close(); + return; + } + const pump = (): Promise => + reader + .read() + .then(({ value: chunk, done }) => { + if (chunk) controller.enqueue(chunk); + if (done) { + controller.close(); + return undefined; + } + return pump(); + }) + .catch(() => { + try { + controller.close(); + } catch { + // already closed (abort/cancel): nothing to do + } + }); + void pump(); + }, + cancel() { + void reader.cancel(); + }, + }); + + return { receipt, stream: replay }; +} + +function isCompleteJson(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed.endsWith('}')) return false; + let depth = 0; + let inString = false; + let escaped = false; + for (const char of trimmed) { + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === '"') inString = !inString; + if (inString) continue; + if (char === '{' || char === '[') depth += 1; + if (char === '}' || char === ']') depth -= 1; + } + return depth === 0; +} + +function parseReceipt(text: string): KernelRunReceipt | null { + try { + const parsed = JSON.parse(text) as Record; + const data = asRecord(parsed.Data); + const status = String(data?.ReceiptStatus || ''); + if (!status) return null; + const error = asRecord(data?.Error); + return { + status, + messageId: (data?.MessageId as string | null) ?? null, + runId: (data?.RunId as string | null) ?? null, + acceptedSeq: (data?.AcceptedSeq as number | null) ?? null, + error: error + ? { code: String(error.code || ''), message: String(error.message || '') } + : null, + }; + } catch { + return null; + } +} diff --git a/src/core/stream/responses-protocol.ts b/src/core/stream/responses-protocol.ts index e0719c2..ad93f82 100644 --- a/src/core/stream/responses-protocol.ts +++ b/src/core/stream/responses-protocol.ts @@ -4,6 +4,10 @@ import { createResponsesStreamState, normalizeResponsesStreamEvent, } from '../../utils/responses-stream.js'; +import { + responsesEventToItemOperations, + type RuntimeItemOperation, +} from './runtime-items.js'; export class ResponsesProtocol implements StreamProtocol { readonly id = 'responses'; @@ -12,6 +16,19 @@ export class ResponsesProtocol implements StreamProtocol { return createResponsesStreamState() as Record; } + /** + * Identity-aware item operations for one transport event, keyed by the + * Responses output item id. This is the schema-v2 path; `parse` remains the + * legacy action projection for the existing dispatcher. + */ + parseItemOperations( + event: TransportEvent, + runId: string, + scopeId: string, + ): RuntimeItemOperation[] { + return responsesEventToItemOperations(event.eventName, event.data as Record, runId, scopeId); + } + parse(event: TransportEvent, state: Record): StreamAction[] { const actions = normalizeResponsesStreamEvent({ eventName: event.eventName, diff --git a/src/core/stream/runtime-items.ts b/src/core/stream/runtime-items.ts new file mode 100644 index 0000000..b1728ff Binary files /dev/null and b/src/core/stream/runtime-items.ts differ diff --git a/src/core/stream/types.ts b/src/core/stream/types.ts index 82b1614..4ab0f0a 100644 --- a/src/core/stream/types.ts +++ b/src/core/stream/types.ts @@ -18,7 +18,15 @@ export type StreamAction = | { type: 'reasoning_delta'; text: string } | { type: 'tool_upsert'; name: string; args: string; status: 'running' | 'completed' | 'error' | 'paused'; extra?: Record } | { type: 'tool_result'; name: string; output: string } - | { type: 'approval_request'; approvalRequestId: string; previousResponseId?: string } + | { + type: 'approval_request'; + approvalRequestId: string; + previousResponseId?: string; + /** Optional legacy transport detail for the unified composer tray. */ + name?: string; + args?: string; + message?: string; + } | { type: 'a2ui_surface_begin'; surfaceId: string; surface: A2UISurface } | { type: 'a2ui_surface_update'; surfaceId: string; surface: A2UISurface } | { type: 'a2ui_surface_end'; surfaceId: string } diff --git a/src/hooks/useBootstrap.ts b/src/hooks/useBootstrap.ts index 518ad25..ddb83e2 100644 --- a/src/hooks/useBootstrap.ts +++ b/src/hooks/useBootstrap.ts @@ -9,6 +9,13 @@ import { readPersistedSessionId } from '../utils/session.js'; import type { UiCapabilities } from '../types/capabilities.js'; import type { BootstrapModel, BootstrapWorkspaceFiles } from '../types/bootstrap.js'; import type { RuntimeApiFormat } from '../types/api.js'; +import { ApiError } from '../api/client.js'; + +export function classifyBootstrapFailure(error: unknown): 'auth-required' | 'error' { + return error instanceof ApiError && (error.code === 401 || error.code === 403) + ? 'auth-required' + : 'error'; +} function normalizeApiFormats(value: unknown): RuntimeApiFormat[] { if (!Array.isArray(value)) { @@ -48,6 +55,7 @@ type SessionCallbacks = { export function useBootstrap(sessionCallbacks: SessionCallbacks) { useEffect(() => { void (async () => { + useBootstrapStore.getState().setStatus('loading'); try { const data = await getAgentUiBootstrap(); const dataRecord = data as Record; @@ -91,19 +99,16 @@ export function useBootstrap(sessionCallbacks: SessionCallbacks) { useModelStore.getState().setModelSource(bootstrapModel.source || ''); } void fetchModels(bootstrapAgentId); + useBootstrapStore.getState().setStatus('ready'); } catch (error) { console.error('Failed to fetch bootstrap:', error); - useBootstrapStore.getState().setCapabilities( - normalizeCapabilities({ - Data: { - Agent: { Framework: '' }, - ApiFormats: ['responses', 'chat_completions'], - Capabilities: {}, - }, - }) as UiCapabilities, + const status = classifyBootstrapFailure(error); + useBootstrapStore.getState().setStatus( + status, + status === 'auth-required' + ? '当前访问链接未授权或已过期。' + : '无法加载 Agent 配置,请稍后重试。', ); - void sessionCallbacks.fetchSessions('default-agent', readPersistedSessionId('default-agent')); - void fetchModels('default-agent'); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/hooks/useInteractions.ts b/src/hooks/useInteractions.ts new file mode 100644 index 0000000..ec95c9d --- /dev/null +++ b/src/hooks/useInteractions.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + InteractionClientImpl, + sharedInteractionStore, +} from '../core/interaction/index.js'; +import type { + Interaction, + InteractionAction, + InteractionReceipt, +} from '../core/interaction/types.js'; +import type { ApiFacade } from '../core/api/types.js'; +import { ksadkA2uiCatalog } from '../core/run/a2ui.js'; + +type UseInteractionsContext = { + agentId: string; + /** Read the current agent id at submit time (bootstrap may arrive late). */ + getAgentId?: () => string; + currentSessionId: string | null; + api: ApiFacade; + /** + * Bootstrap-advertised `interaction_v1`. When absent the client keeps + * the 0.3.1 Responses/AG-UI fallback callbacks. + */ + interactionV1Enabled: boolean; + /** 0.3.1 fallback: official `mcp_approval_response` submission. */ + legacyResponsesApproval?: (approvalRequestId: string, approve: boolean) => void; + /** 0.3.1 fallback: AG-UI `resumeAguiInterrupt`. */ + legacyAguiResume?: ( + interruptId: string, + status: 'resolved' | 'cancelled', + payload?: unknown, + ) => boolean; +}; + +export function useInteractions(ctx: UseInteractionsContext) { + const [client] = useState( + () => + new InteractionClientImpl({ + agentId: ctx.agentId, + store: sharedInteractionStore, + submitInteraction: (params) => + ctx.api.submitInteraction({ + ...params, + AgentId: ctx.getAgentId?.() ?? params.AgentId, + }), + interactionV1Enabled: ctx.interactionV1Enabled, + legacyResponsesApproval: ctx.legacyResponsesApproval, + legacyAguiResume: ctx.legacyAguiResume, + }), + ); + + // Keep capability routing current (capability can change after bootstrap). + useEffect(() => { + client.setInteractionV1Enabled(ctx.interactionV1Enabled); + }, [client, ctx.interactionV1Enabled]); + + const [version, setVersion] = useState(0); + useEffect(() => { + return sharedInteractionStore.subscribe(() => setVersion((v) => v + 1)); + }, []); + + const pending = useMemo(() => { + void version; + return ctx.currentSessionId + ? sharedInteractionStore + .listAll(ctx.currentSessionId) + .filter( + (interaction) => + interaction.status === 'pending' + || interaction.status === 'resolving' + || interaction.status === 'failed', + ) + : []; + }, [version, ctx.currentSessionId]); + + const records = useMemo(() => { + void version; + return ctx.currentSessionId + ? sharedInteractionStore.listAll(ctx.currentSessionId) + : []; + }, [version, ctx.currentSessionId]); + + const respond = useCallback( + (input: { + interactionId: string; + expectedRevision: number; + action: InteractionAction; + response: Record; + idempotencyKey: string; + }): Promise => client.respond(input), + [client], + ); + + return { + client, + pending, + records, + respond, + localCatalog: ksadkA2uiCatalog, + }; +} diff --git a/src/hooks/useRunAgent.ts b/src/hooks/useRunAgent.ts index c557483..c816e8f 100644 --- a/src/hooks/useRunAgent.ts +++ b/src/hooks/useRunAgent.ts @@ -11,11 +11,12 @@ import type { ModelCatalogItem, Session } from '../components/chat/types.js'; import { writePersistedSessionId } from '../utils/session.js'; import { resolveHostedChatTransport } from '../utils/capabilities.js'; import type { A2UIClientEventMessage } from '@copilotkit/a2ui-renderer'; -import type { PermissionMode } from '../core/run/types.js'; +import type { PermissionMode, RuntimeExecutionMode } from '../core/run/types.js'; type QueuedDraft = { text: string; attachments: File[]; + executionMode?: RuntimeExecutionMode; }; type RunAgentContext = { @@ -32,7 +33,7 @@ type RunAgentContext = { api: ApiFacade; currentSessionIdRef: React.MutableRefObject; agentIdRef: React.MutableRefObject; - queuedDraftRef: React.MutableRefObject>; + queuedDraftRef: React.MutableRefObject; onRunSettled?: (sessionId: string | null) => void; }; @@ -61,8 +62,9 @@ export function useRunAgent(ctx: RunAgentContext) { agentFramework, selectedModel, selectedModelMetadata, - thinkingMode, + thinkingMode: uiCapabilities.Thinking ? thinkingMode : 'auto', permissionMode, + runtimeCapabilityMatrix: uiCapabilities.RuntimeCapabilityMatrix, hostedChatTransport: resolveHostedChatTransport(uiCapabilities, { requireResumableRun: Boolean( uiCapabilities.RunLifecycle?.Enabled && uiCapabilities.RunLifecycle.Resume, @@ -135,6 +137,7 @@ export function useRunAgent(ctx: RunAgentContext) { attachments: draft.attachments, responsesInput: draft.responsesInput, previousResponseId: draft.previousResponseId, + executionMode: draft.executionMode, sessionId: targetSessionId, onSessionCreated: (sessionId: string) => { useSessionStore.getState().upsertSessions([{ SessionId: sessionId, UpdatedAt: new Date().toISOString() } as unknown as Session]); @@ -183,24 +186,26 @@ export function useRunAgent(ctx: RunAgentContext) { draftAttachments: File[], responsesInput?: unknown, previousResponseId?: string, + executionMode?: RuntimeExecutionMode, ) => { const draft = { text: draftText, attachments: draftAttachments, responsesInput, previousResponseId, + executionMode, }; const engine = getEngine(currentSessionIdRef.current); if (engine.stage !== 'idle' && useStreamingStore.getState().isSessionStreaming(currentSessionIdRef.current)) { if (responsesInput === undefined) { - enqueueDraft({ text: draftText, attachments: draftAttachments }); + enqueueDraft({ text: draftText, attachments: draftAttachments, executionMode }); } return; } if (!startDraft(draft) && responsesInput === undefined) { - enqueueDraft({ text: draftText, attachments: draftAttachments }); + enqueueDraft({ text: draftText, attachments: draftAttachments, executionMode }); } }, [currentSessionIdRef, enqueueDraft, getEngine, startDraft], diff --git a/src/hooks/useSessionLifecycle.ts b/src/hooks/useSessionLifecycle.ts index e14729e..f745382 100644 --- a/src/hooks/useSessionLifecycle.ts +++ b/src/hooks/useSessionLifecycle.ts @@ -20,6 +20,8 @@ import type { UiCapabilities } from '../types/capabilities.js'; import type { ApiFacade } from '../core/api/types.js'; import { dispatchRunEventToStores } from '../core/run/dispatcher.js'; import { parseSseChunk, splitSseBuffer } from '../core/transport/sse-parser.js'; +import { createSessionEventCursor } from '../utils/session-event-history.js'; +import { ingestSessionEventRecord } from '../core/interaction/index.js'; const RESTORE_RECONNECT_DELAY_MS = 500; const SESSION_LIST_PAGE_SIZE = 30; @@ -104,6 +106,9 @@ export function useSessionLifecycle(ctx: SessionLifecycleContext) { const currentSessionIdRef = useRef(ctx.currentSessionId); const agentIdRef = useRef(ctx.agentId); const runSubscriptionAbortRef = useRef(null); + // agent-kernel/v1 unified cursor: only the Session seq dedupes/orders and + // drives reconnects; Responses/AG-UI/A2A internal event ids are ignored. + const sessionEventCursorRef = useRef(createSessionEventCursor()); const loadSessionGenerationRef = useRef(0); const olderMessageRequestRef = useRef(new Map()); const loadSessionRef = useRef<((sessionId: string) => Promise) | null>(null); @@ -246,6 +251,35 @@ export function useSessionLifecycle(ctx: SessionLifecycleContext) { if (!isCurrentSubscription()) break; if (event.InvocationId && event.InvocationId !== options.invocationId) continue; + const kernelSeq = (event as { seq?: unknown }).seq; + if (typeof kernelSeq === 'number') { + // agent-kernel/v1 envelope: fold into the unified cursor. + try { + sessionEventCursorRef.current.accept(event); + } catch (cursorError) { + console.error('[SessionLifecycle] session event cursor conflict:', cursorError); + } + afterSeqId = Math.max(afterSeqId, sessionEventCursorRef.current.reconnectAfterSeq()); + dispatchRunEventToStores({ + type: 'stream_event', + sessionId: options.sessionId, + event, + }); + terminalStatusSeen = terminalStatusSeen || eventHasTerminalRunStatus(event); + shouldReloadSession = shouldReloadSession || terminalStatusSeen; + const kernelTerminal = terminalActivityForRunEvent(event); + if (kernelTerminal) { + useStreamingStore.getState().updateActivity({ + sessionId: options.sessionId, + status: kernelTerminal.status, + phase: kernelTerminal.phase, + detail: options.invocationId, + countEvent: false, + }); + } + continue; + } + const seqId = Number(event.SeqId || 0); if (Number.isFinite(seqId)) { afterSeqId = Math.max(afterSeqId, seqId); @@ -365,6 +399,19 @@ export function useSessionLifecycle(ctx: SessionLifecycleContext) { void loadFeedbackForMessages(agentIdRef.current, sessionId, history); const lastSeqId = messagesData.LatestSeqId || 0; + // Replay recent durable SessionEvents to restore pending + // Interaction/v1 records (refresh / replay never re-submits). + try { + const eventsData = await api.listSessionEvents(sessionId, { limit: 50 }); + if (isStillCurrentSession()) { + for (const record of (eventsData.Events || []) as unknown[]) { + ingestSessionEventRecord(record, sessionId); + } + } + } catch (error) { + console.warn('[SessionLifecycle] interaction history replay failed:', error); + } + const runtimeCapabilities = useBootstrapStore.getState().capabilities || uiCapabilities; if (runtimeCapabilities.RunLifecycle.Enabled && runtimeCapabilities.RunLifecycle.Checkpoints) { void api.listSessionCheckpoints({ @@ -434,7 +481,6 @@ export function useSessionLifecycle(ctx: SessionLifecycleContext) { }, [ api, - disconnectRun, isMobile, loadFeedbackForMessages, resetCompaction, @@ -571,7 +617,15 @@ export function useSessionLifecycle(ctx: SessionLifecycleContext) { const deleteSession = useCallback( async (sessionId: string) => { try { - await api.deleteSession(sessionId); + const result = await api.deleteSession(sessionId); + if (result.Deleted === false) { + useUIStore.getState().pushToast( + '会话暂未删除,云端运行时仍在同步,请稍后重试。', + 'error', + ); + void fetchSessions(agentId, currentSessionIdRef.current ?? undefined); + return; + } useSessionStore.getState().removeSession(sessionId); useSessionStore.getState().clearSessionMessageHistory(sessionId); if (currentSessionIdRef.current === sessionId) { @@ -589,6 +643,7 @@ export function useSessionLifecycle(ctx: SessionLifecycleContext) { } catch (error) { if (error instanceof CancelledError) return; console.error('Failed to delete session', error); + useUIStore.getState().pushToast('删除会话失败,请稍后重试。', 'error'); } }, [agentId, api, disconnectRun, fetchSessions], diff --git a/src/public/runtime.ts b/src/public/runtime.ts index ad56214..b7dc8b9 100644 --- a/src/public/runtime.ts +++ b/src/public/runtime.ts @@ -8,3 +8,44 @@ export { createProtocol } from '../core/stream/index.js'; export type { StreamAction, StreamProtocol } from '../core/stream/types.js'; export { SseGetTransport, SsePostTransport } from '../core/transport/sse-transport.js'; export type { RuntimeTransport, TransportCallbacks, TransportEvent } from '../core/transport/types.js'; +export { + ContractMismatchError, + decodeAgentStatusSnapshot, + decodeCapabilityMatrix, + decodeReceipt, + decodeSessionEventEnvelope, +} from '../types/agent-control.js'; +export type { + AgentControlError, + AgentControlReceipt, + AgentControlReceiptStatus, + AgentStatusSnapshot, + RuntimeCapability, + RuntimeCapabilityMatrix, + SessionEventEnvelope, +} from '../types/agent-control.js'; +export { + SessionEventConflictError, + SessionEventCursor, + createSessionEventCursor, +} from '../utils/session-event-history.js'; +export { InteractionClientImpl, InteractionStore } from '../core/interaction/index.js'; +export type { + Interaction, + InteractionAction, + InteractionClient, + InteractionEvent, + InteractionReceipt, + InteractionStatus, + InteractionSubmitInput, +} from '../core/interaction/index.js'; +export { + interactionFromSessionEvent, + interactionFromResponsesApproval, + interactionFromAguiInterrupt, + interactionIdempotencyKey, +} from '../core/interaction/index.js'; +export { + A2UI_WIRE_VERSION, + validateA2uiPresentation, +} from '../core/interaction/index.js'; diff --git a/src/stores/bootstrap.ts b/src/stores/bootstrap.ts index 114362d..0da3bc9 100644 --- a/src/stores/bootstrap.ts +++ b/src/stores/bootstrap.ts @@ -3,6 +3,8 @@ import { normalizeCapabilities } from '../utils/capabilities.js'; import type { RuntimeApiFormat } from '../types/api.js'; import type { UiCapabilities } from '../types/capabilities.js'; +export type BootstrapStatus = 'loading' | 'ready' | 'auth-required' | 'error'; + export type BootstrapState = { agentId: string; agentName: string; @@ -11,6 +13,8 @@ export type BootstrapState = { apiFormats: RuntimeApiFormat[]; accessMode: string; workspaceFiles: Record | null; + status: BootstrapStatus; + errorMessage: string; }; export type BootstrapActions = { @@ -21,6 +25,7 @@ export type BootstrapActions = { setApiFormats: (formats: RuntimeApiFormat[]) => void; setAccessMode: (mode: string) => void; setWorkspaceFiles: (files: Record | null) => void; + setStatus: (status: BootstrapStatus, errorMessage?: string) => void; }; export type BootstrapStore = BootstrapState & BootstrapActions; @@ -33,6 +38,8 @@ export const useBootstrapStore = create()((set) => ({ apiFormats: ['responses', 'chat_completions'], accessMode: 'Owner', workspaceFiles: null, + status: 'loading', + errorMessage: '', setAgentId: (id) => set({ agentId: id }), setAgentName: (name) => set({ agentName: name }), setAgentFramework: (framework) => set({ agentFramework: framework }), @@ -40,4 +47,5 @@ export const useBootstrapStore = create()((set) => ({ setApiFormats: (formats) => set({ apiFormats: formats }), setAccessMode: (mode) => set({ accessMode: mode }), setWorkspaceFiles: (files) => set({ workspaceFiles: files }), + setStatus: (status, errorMessage = '') => set({ status, errorMessage }), })); diff --git a/src/stores/streaming.ts b/src/stores/streaming.ts index fe5ad3e..f1e5689 100644 --- a/src/stores/streaming.ts +++ b/src/stores/streaming.ts @@ -1,5 +1,11 @@ import { create } from 'zustand'; +import { + RuntimeItemReducer, + type RuntimeItemOperation, + type RuntimeItemSnapshot, +} from '../core/stream/runtime-items.js'; + export type RunActivityStatus = 'connecting' | 'running' | 'waiting' | 'stopped' | 'completed' | 'failed'; export type StatusBannerKind = 'rate_limited' | 'network' | 'error'; @@ -35,6 +41,8 @@ export type StreamingState = { lastSeqId: number; /** 当前 run 的 invocationId(afterSeqId 续订需要)。 */ activeInvocationId: string; + /** 当前 run 的 identity-aware item 投影(schema v2),替代 v1 启发式去重。 */ + runtimeItems: RuntimeItemSnapshot; }; export type StreamingActions = { @@ -66,6 +74,11 @@ export type StreamingActions = { stopSessionActivity: (sessionId?: string | null, detail?: string) => void; clearActivity: () => void; clearSessionActivity: (sessionId?: string | null) => void; + /** + * 应用 identity-aware item 操作到当前 run 的 RuntimeItemReducer。 + * 操作按 runId/scopeId/itemId/partId 归并;eventId 重放是幂等 no-op。 + */ + applyRuntimeItemOperations: (operations: RuntimeItemOperation[]) => void; resetRun: () => void; }; @@ -73,6 +86,14 @@ export type StreamingStore = StreamingState & StreamingActions; const hasStreamingSession = (sessions: Record): boolean => Object.keys(sessions).length > 0; +const EMPTY_RUNTIME_ITEMS: RuntimeItemSnapshot = { runId: null, status: null, items: [] }; + +/** + * 当前 run 的 canonical reducer 实例。放在 store state 外(reducer 自身是可变 + * accumulator),state 里只暴露只读 snapshot,保证 React 依赖 snapshot 引用变化重渲染。 + */ +let runtimeItemReducer = new RuntimeItemReducer(); + const withoutStreamingSession = (sessions: Record, key: string): Record => { const next = { ...sessions }; delete next[key]; @@ -89,6 +110,7 @@ export const useStreamingStore = create()((set, get) => ({ banner: null, lastSeqId: 0, activeInvocationId: '', + runtimeItems: EMPTY_RUNTIME_ITEMS, setStreaming: (streaming) => set({ isStreaming: streaming }), setBanner: (banner) => set({ banner: banner ? { ...banner, createdAt: Date.now() } : null, @@ -217,7 +239,13 @@ export const useStreamingStore = create()((set, get) => ({ activity: state.activity === _removed ? null : state.activity, }; }), - resetRun: () => set({ + applyRuntimeItemOperations: (operations) => { + runtimeItemReducer.applyAll(operations); + set({ runtimeItems: runtimeItemReducer.snapshot() }); + }, + resetRun: () => { + runtimeItemReducer = new RuntimeItemReducer(); + set({ isStreaming: false, currentRunId: '', stopRequested: false, @@ -226,5 +254,7 @@ export const useStreamingStore = create()((set, get) => ({ sessionStreaming: {}, lastSeqId: 0, activeInvocationId: '', - }), + runtimeItems: EMPTY_RUNTIME_ITEMS, + }); + }, })); diff --git a/src/stores/ui.ts b/src/stores/ui.ts index 3d0f707..9dc3c9c 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -1,5 +1,12 @@ import { create } from 'zustand'; import type { MessageAttachment, PreviewImageSize } from '../components/chat/types.js'; +import type { RuntimeExecutionMode } from '../core/run/types.js'; + +type QueuedDraft = { + text: string; + attachments: File[]; + executionMode?: RuntimeExecutionMode; +}; type ToastEntry = { id: string; @@ -19,7 +26,7 @@ export type UIState = { attachments: File[]; previewAttachment: MessageAttachment | null; previewImageSize: PreviewImageSize | null; - queuedDrafts: Array<{ text: string; attachments: File[] }>; + queuedDrafts: QueuedDraft[]; toasts: ToastEntry[]; }; @@ -37,7 +44,7 @@ export type UIActions = { setAttachments: (updater: File[] | ((prev: File[]) => File[])) => void; setPreviewAttachment: (attachment: MessageAttachment | null) => void; setPreviewImageSize: (size: PreviewImageSize | null) => void; - setQueuedDrafts: (updater: Array<{ text: string; attachments: File[] }> | ((prev: Array<{ text: string; attachments: File[] }>) => Array<{ text: string; attachments: File[] }>)) => void; + setQueuedDrafts: (updater: QueuedDraft[] | ((prev: QueuedDraft[]) => QueuedDraft[])) => void; pushToast: (message: string, variant?: 'error' | 'info') => void; dismissToast: (id: string) => void; }; diff --git a/src/types/agent-control.ts b/src/types/agent-control.ts new file mode 100644 index 0000000..f7b7bc0 --- /dev/null +++ b/src/types/agent-control.ts @@ -0,0 +1,334 @@ +/** + * agent-kernel/v1 contract types and strict decoders. + * + * Hand-written from the canonical JSON schemas in + * `contracts/agent-kernel/v1/` (aggregate digest + * 69771d8df4a8811ed6623f26a152c869cfdfd8dbfdcad443b52a9b6403b267e8). + * + * Decoding rules: + * - Required fields and enumerated values are validated strictly; a mismatch + * throws {@link ContractMismatchError} and callers must stop mutating state. + * - Unknown optional fields are preserved in `extensions` for forward + * compatibility instead of being dropped silently. + */ +import { z } from 'zod'; +import { decodeCapabilityMatrixValue } from './runtime-capability-matrix.js'; + +export class ContractMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = 'ContractMismatchError'; + } +} + +// --------------------------------------------------------------------------- +// AgentControlReceipt/v1 +// --------------------------------------------------------------------------- + +export type AgentControlCommandType = + | 'enqueue' | 'steer' | 'inject' | 'interrupt' + | 'pause' | 'resume' | 'submit_interaction'; + +export type AgentControlReceiptStatus = + | 'accepted' | 'duplicate' | 'rejected' | 'unsupported' + | 'queue_full' | 'persistence_uncertain'; + +export type AgentControlError = { + code: string; + message: string; + retryable: boolean; + details?: Record; + extensions: Record; +}; + +export type AgentControlReceipt = { + schema_version: 1; + command_id: string; + status: AgentControlReceiptStatus; + message_id?: string | null; + run_id?: string | null; + accepted_seq?: number | null; + error?: AgentControlError | null; + /** Unknown optional fields kept verbatim. */ + extensions: Record; +}; + +const RECEIPT_KNOWN_KEYS = new Set([ + 'schema_version', 'command_id', 'status', 'message_id', 'run_id', + 'accepted_seq', 'error', +]); + +const controlErrorShape = z.object({ + code: z.string().min(1), + message: z.string(), + retryable: z.boolean(), + details: z.record(z.string(), z.unknown()).optional(), +}); + +export function decodeReceipt(raw: unknown): AgentControlReceipt { + const parsed = z + .object({ + schema_version: z.literal(1), + command_id: z.string().min(1), + status: z.enum([ + 'accepted', 'duplicate', 'rejected', 'unsupported', + 'queue_full', 'persistence_uncertain', + ]), + message_id: z.string().min(1).nullable().optional(), + run_id: z.string().min(1).nullable().optional(), + accepted_seq: z.number().int().min(0).nullable().optional(), + error: controlErrorShape.nullable().optional(), + }) + .passthrough() + .safeParse(raw); + if (!parsed.success) { + throw new ContractMismatchError( + `AgentControlReceipt/v1 mismatch: ${parsed.error.message}`, + ); + } + const value = parsed.data; + if ( + (value.status === 'rejected' + || value.status === 'unsupported' + || value.status === 'queue_full' + || value.status === 'persistence_uncertain') + && !value.error + ) { + throw new ContractMismatchError( + `AgentControlReceipt/v1 mismatch: status ${value.status} requires error`, + ); + } + const extensions: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (!RECEIPT_KNOWN_KEYS.has(key)) { + extensions[key] = entry; + } + } + const error = value.error + ? { ...value.error, extensions: {} as Record } + : null; + return { + schema_version: 1, + command_id: value.command_id, + status: value.status, + message_id: value.message_id ?? null, + run_id: value.run_id ?? null, + accepted_seq: value.accepted_seq ?? null, + error, + extensions, + }; +} + +// --------------------------------------------------------------------------- +// RuntimeCapabilityMatrix/v1 +// --------------------------------------------------------------------------- + +export type RuntimeCapabilityMode = 'native' | 'emulated' | 'unavailable'; + +export type RuntimeCapability = { + supported: boolean; + mode: RuntimeCapabilityMode; + reason?: string | null; + extensions: Record; +}; + +export type RuntimeCapabilityMatrix = { + schema_version: 1; + cancel: RuntimeCapability; + pause: RuntimeCapability; + resume: RuntimeCapability; + submit_interaction: RuntimeCapability; + attach: RuntimeCapability; + steer: RuntimeCapability; + inject: RuntimeCapability; + checkpoint: RuntimeCapability; + durable_restore: RuntimeCapability; + /** Runtime v2 execution modes are additive and absent on legacy runtimes. */ + goal?: RuntimeCapability; + loop?: RuntimeCapability; + plan?: RuntimeCapability; + extensions: Record; +}; + +export function decodeCapabilityMatrix(raw: unknown): RuntimeCapabilityMatrix { + try { + return decodeCapabilityMatrixValue(raw) as RuntimeCapabilityMatrix; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new ContractMismatchError(`RuntimeCapabilityMatrix/v1 mismatch: ${detail}`); + } +} + +// --------------------------------------------------------------------------- +// SessionEventEnvelope/v1 +// --------------------------------------------------------------------------- + +export type SessionEventFamily = + | 'control' | 'runtime' | 'workflow' | 'schedule' | 'job' | 'relationship'; + +export type SessionEventEnvelope = { + schema_version: 1; + event_id: string; + session_id: string; + seq: number; + timestamp: string; + family: string; + family_version: number; + event_type: string; + payload: Record; + run_id?: string | null; + causation_id?: string | null; + correlation_id?: string | null; + actor_ref?: string | null; + extensions: Record; +}; + +export type DecodedSessionEventEnvelope = + | { ok: true; value: SessionEventEnvelope } + | { ok: false; error: ContractMismatchError }; + +const ENVELOPE_KNOWN_KEYS = new Set([ + 'schema_version', 'event_id', 'session_id', 'seq', 'timestamp', + 'family', 'family_version', 'event_type', 'payload', 'run_id', + 'causation_id', 'correlation_id', 'actor_ref', +]); + +const KNOWN_FAMILIES: ReadonlyMap = new Map([ + ['control', 1], + ['runtime', 2], +]); + +const envelopeSchema = z + .object({ + schema_version: z.literal(1), + event_id: z.string().min(1), + session_id: z.string().min(1), + seq: z.number().int().min(0), + timestamp: z.string().min(1), + family: z.string().min(1), + family_version: z.number().int().min(1), + event_type: z.string().min(1), + payload: z.record(z.string(), z.unknown()), + run_id: z.string().min(1).nullable().optional(), + causation_id: z.string().min(1).nullable().optional(), + correlation_id: z.string().min(1).nullable().optional(), + actor_ref: z.string().min(1).nullable().optional(), + }) + .passthrough(); + +/** + * Decode a session event envelope. Unknown families decode successfully so + * the cursor can still advance; only structural violations fail. + */ +export function decodeSessionEventEnvelope(raw: unknown): DecodedSessionEventEnvelope { + const parsed = envelopeSchema.safeParse(raw); + if (!parsed.success) { + return { + ok: false, + error: new ContractMismatchError( + `SessionEventEnvelope/v1 mismatch: ${parsed.error.message}`, + ), + }; + } + const value = parsed.data; + const expectedVersion = KNOWN_FAMILIES.get(value.family); + if (expectedVersion !== undefined && value.family_version !== expectedVersion) { + return { + ok: false, + error: new ContractMismatchError( + `SessionEventEnvelope/v1 mismatch: family ${value.family} requires family_version ${expectedVersion}`, + ), + }; + } + const extensions: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (!ENVELOPE_KNOWN_KEYS.has(key)) { + extensions[key] = entry; + } + } + return { + ok: true, + value: { + schema_version: 1, + event_id: value.event_id, + session_id: value.session_id, + seq: value.seq, + timestamp: value.timestamp, + family: value.family, + family_version: value.family_version, + event_type: value.event_type, + payload: value.payload, + run_id: value.run_id ?? null, + causation_id: value.causation_id ?? null, + correlation_id: value.correlation_id ?? null, + actor_ref: value.actor_ref ?? null, + extensions, + }, + }; +} + +// --------------------------------------------------------------------------- +// AgentStatusSnapshot/v1 +// --------------------------------------------------------------------------- + +export type AgentStatusSnapshot = { + schema_version: 1; + agent_instance_id: string; + instance_state: 'ready' | 'degraded' | 'unavailable'; + session_id?: string | null; + active_run_id?: string | null; + active_run_state?: 'pending' | 'running' | 'paused' | 'waiting' | null; + inbox_depth: number; + activation_id?: string | null; + lease_expires_at?: string | null; + capability: RuntimeCapabilityMatrix; + extensions: Record; +}; + +export function decodeAgentStatusSnapshot(raw: unknown): AgentStatusSnapshot { + const parsed = z + .object({ + schema_version: z.literal(1), + agent_instance_id: z.string().min(1), + instance_state: z.enum(['ready', 'degraded', 'unavailable']), + session_id: z.string().min(1).nullable().optional(), + active_run_id: z.string().min(1).nullable().optional(), + active_run_state: z + .enum(['pending', 'running', 'paused', 'waiting']) + .nullable() + .optional(), + inbox_depth: z.number().int().min(0), + activation_id: z.string().min(1).nullable().optional(), + lease_expires_at: z.string().min(1).nullable().optional(), + capability: z.unknown(), + }) + .passthrough() + .safeParse(raw); + if (!parsed.success) { + throw new ContractMismatchError( + `AgentStatusSnapshot/v1 mismatch: ${parsed.error.message}`, + ); + } + const value = parsed.data; + const extensions: Record = {}; + for (const key of Object.keys(value as Record)) { + if (!['schema_version', 'agent_instance_id', 'instance_state', 'session_id', + 'active_run_id', 'active_run_state', 'inbox_depth', 'activation_id', + 'lease_expires_at', 'capability'].includes(key)) { + extensions[key] = (value as Record)[key]; + } + } + return { + schema_version: 1, + agent_instance_id: value.agent_instance_id, + instance_state: value.instance_state, + session_id: value.session_id ?? null, + active_run_id: value.active_run_id ?? null, + active_run_state: value.active_run_state ?? null, + inbox_depth: value.inbox_depth, + activation_id: value.activation_id ?? null, + lease_expires_at: value.lease_expires_at ?? null, + capability: decodeCapabilityMatrix(value.capability), + extensions, + }; +} diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts index f29e000..e6fc2dc 100644 --- a/src/types/capabilities.ts +++ b/src/types/capabilities.ts @@ -3,6 +3,7 @@ import type { HostedChatTransportProtocol, RuntimeApiFormat, } from './api.js'; +import type { RuntimeCapabilityMatrix } from './agent-control.js'; export type BuiltinToolCapability = { name: string; @@ -58,4 +59,17 @@ export type UiCapabilities = { }; WorkspaceFiles?: boolean; BuiltinTools: BuiltinToolCapability[]; + /** agent-kernel/v1 Interaction capability (bootstrap `interaction_v1`). */ + InteractionV1?: boolean; + /** Canonical agent-kernel/v1 projection. Absent means no execution-mode UI. */ + RuntimeCapabilityMatrix?: RuntimeCapabilityMatrix; }; + +// agent-kernel/v1 runtime capability matrix, exposed alongside the legacy +// UI capability projection so consumers gate on canonical contracts. +export type { + RuntimeCapability, + RuntimeCapabilityMatrix, + RuntimeCapabilityMode, +} from './agent-control.js'; +export { decodeCapabilityMatrix } from './agent-control.js'; diff --git a/src/types/runtime-capability-matrix.js b/src/types/runtime-capability-matrix.js new file mode 100644 index 0000000..2c866ef --- /dev/null +++ b/src/types/runtime-capability-matrix.js @@ -0,0 +1,94 @@ +const REQUIRED_CAPABILITY_KEYS = [ + 'cancel', + 'pause', + 'resume', + 'submit_interaction', + 'attach', + 'steer', + 'inject', + 'checkpoint', + 'durable_restore', +]; + +const EXECUTION_MODE_KEYS = ['goal', 'loop', 'plan']; +const CAPABILITY_MODES = new Set(['native', 'emulated', 'unavailable']); +const CAPABILITY_VALUE_KEYS = new Set(['supported', 'mode', 'reason']); +const MATRIX_KEYS = new Set([ + 'schema_version', + ...REQUIRED_CAPABILITY_KEYS, + ...EXECUTION_MODE_KEYS, +]); + +function asRecord(value, field) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + return value; +} + +function decodeCapabilityValue(raw, field) { + const value = asRecord(raw, field); + if (typeof value.supported !== 'boolean') { + throw new Error(`${field}.supported must be a boolean`); + } + if (!CAPABILITY_MODES.has(value.mode)) { + throw new Error(`${field}.mode must be native, emulated, or unavailable`); + } + if ( + value.reason !== undefined + && value.reason !== null + && (typeof value.reason !== 'string' || value.reason.length === 0) + ) { + throw new Error(`${field}.reason must be a non-empty string or null`); + } + if (!value.supported && (value.mode !== 'unavailable' || !value.reason)) { + throw new Error( + `${field}: unsupported capability requires mode=unavailable and reason`, + ); + } + + return { + supported: value.supported, + mode: value.mode, + reason: value.reason ?? null, + extensions: Object.fromEntries( + Object.entries(value).filter(([key]) => !CAPABILITY_VALUE_KEYS.has(key)), + ), + }; +} + +/** + * Decode the runtime-only portion of AgentKernel RuntimeCapabilityMatrix/v1. + * + * This module intentionally stays plain JavaScript so both the browser bundle + * and the repository's pre-build Node compatibility tests execute the same + * fail-closed decoder. The public TypeScript wrapper supplies the contract + * type and canonical ContractMismatchError. + */ +export function decodeCapabilityMatrixValue(raw) { + const value = asRecord(raw, 'RuntimeCapabilityMatrix/v1'); + if (value.schema_version !== 1) { + throw new Error('schema_version must equal 1'); + } + for (const key of REQUIRED_CAPABILITY_KEYS) { + if (!(key in value)) { + throw new Error(`${key} is required`); + } + } + + const matrix = { + schema_version: 1, + extensions: Object.fromEntries( + Object.entries(value).filter(([key]) => !MATRIX_KEYS.has(key)), + ), + }; + for (const key of REQUIRED_CAPABILITY_KEYS) { + matrix[key] = decodeCapabilityValue(value[key], key); + } + for (const key of EXECUTION_MODE_KEYS) { + if (value[key] !== undefined && value[key] !== null) { + matrix[key] = decodeCapabilityValue(value[key], key); + } + } + return matrix; +} diff --git a/src/utils/capabilities.js b/src/utils/capabilities.js index 10f1910..ed5dbff 100644 --- a/src/utils/capabilities.js +++ b/src/utils/capabilities.js @@ -1,3 +1,5 @@ +import { decodeCapabilityMatrixValue } from '../types/runtime-capability-matrix.js'; + const DEFAULT_API_FORMATS = ['responses', 'chat_completions']; const NATIVE_DASHBOARD_FRAMEWORKS = new Map([ ['openclaw', 'OpenClaw'], @@ -74,6 +76,16 @@ function normalizeApprovalPolicy(value) { }; } +function normalizeRuntimeCapabilityMatrix(value) { + if (value === undefined || value === null) return undefined; + try { + return decodeCapabilityMatrixValue(value); + } catch { + // A malformed or legacy-shaped capability must never enable controls. + return undefined; + } +} + function normalizeHostedChatTransports(value) { if (!Array.isArray(value)) { return []; @@ -137,9 +149,20 @@ export function normalizeCapabilities(bootstrap) { ); const nativeTerminalEnabled = normalizeEnabled(nativeTerminal.Enabled, defaultNativeTerminal); const runLifecycleEnabled = normalizeEnabled(runLifecycle.Enabled, hostedChatEnabled); + const interactionV1 = Boolean( + rawCapabilities.interaction_v1?.enabled + ?? rawCapabilities.Interaction?.V1 + ?? rawCapabilities.InteractionV1 + ?? false, + ); + const runtimeCapabilityMatrix = normalizeRuntimeCapabilityMatrix( + rawCapabilities.RuntimeCapabilityMatrix ?? data.RuntimeCapabilityMatrix, + ); return { ...rawCapabilities, + RuntimeCapabilityMatrix: runtimeCapabilityMatrix, + InteractionV1: interactionV1, HostedChat: { Enabled: hostedChatEnabled, ApiFormats: normalizeApiFormats(hostedChat.ApiFormats || apiFormats), diff --git a/src/utils/responses-stream.js b/src/utils/responses-stream.js index 42a3e20..2a7a974 100644 --- a/src/utils/responses-stream.js +++ b/src/utils/responses-stream.js @@ -1,3 +1,11 @@ +// Identity-aware (schema v2) item operations live in the canonical core reducer. +// Re-exported here so the Responses ingress and its tests share one source of truth. +export { + RuntimeItemReducer, + responsesEventToItemOperations, + projectRuntimeItems, +} from '../core/stream/runtime-items.ts'; + export function createResponsesStreamState() { return { toolNames: new Map(), @@ -162,16 +170,29 @@ function normalizeOutputItem({ data, state, status }) { if (type === 'mcp_approval_request') { const name = String(item.name || 'approval'); + const args = stringifyPayload(item.arguments ?? item.args); + const approvalRequestId = String(item.id || item.approval_request_id || ''); return [ { type: 'tool_upsert', name, - args: stringifyPayload(item.arguments ?? item.args), + args, status: 'paused', - approvalRequestId: String(item.id || item.approval_request_id || ''), + approvalRequestId, previousResponseId: String(data?.response_id || state.currentResponseId || ''), serverLabel: String(item.server_label || ''), }, + // The transcript retains its immutable tool row, while the same + // pending decision is normalized into the composer-adjacent + // Interaction queue. This is essential for 0.3.1 Responses servers + // which have not yet enabled durable Interaction/v1 events. + { + type: 'approval_request', + approvalRequestId, + previousResponseId: String(data?.response_id || state.currentResponseId || ''), + name, + args, + }, ]; } @@ -310,7 +331,7 @@ export function normalizeResponsesStreamEvent({ eventName, data, state }) { : interruptInfo; const actionRequests = Array.isArray(ar.action_requests) ? ar.action_requests : []; if (actionRequests.length > 0) { - return actionRequests.map((req) => ({ + const tools = actionRequests.map((req) => ({ type: 'tool_upsert', name: String(req?.name || 'tool'), args: JSON.stringify(req?.args ?? {}), @@ -318,6 +339,17 @@ export function normalizeResponsesStreamEvent({ eventName, data, state }) { approvalRequestId, previousResponseId, })); + const first = actionRequests[0] || {}; + return [ + ...tools, + { + type: 'approval_request', + approvalRequestId, + previousResponseId, + name: String(first?.name || '人工确认'), + args: JSON.stringify(first?.args ?? {}), + }, + ]; } return [ { diff --git a/src/utils/session-event-history.ts b/src/utils/session-event-history.ts index b07bcb8..da019e8 100644 --- a/src/utils/session-event-history.ts +++ b/src/utils/session-event-history.ts @@ -95,3 +95,89 @@ export async function loadCompleteSessionEventHistory( limit: merged.length, }; } + +// --------------------------------------------------------------------------- +// agent-kernel/v1 unified session cursor +// --------------------------------------------------------------------------- + +import { + decodeSessionEventEnvelope, + type SessionEventEnvelope, +} from '../types/agent-control.js'; + +/** Two events sharing a seq but differing in content are a protocol error. */ +export class SessionEventConflictError extends Error { + seq: number; + + constructor(seq: number) { + super(`Session event seq ${seq} was received twice with conflicting content`); + this.name = 'SessionEventConflictError'; + this.seq = seq; + } +} + +function stableHash(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableHash).join(',')}]`; + } + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableHash(v)}`).join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +} + +const DISPLAYABLE_FAMILIES = new Set(['runtime']); + +/** + * Deduplication/ordering state keyed strictly by the Session seq. + * + * Internal event ids from Responses/AG-UI/A2A projections are never used as a + * reconnect cursor. Refresh replays are folded in before live events: an + * exact duplicate seq is ignored, a conflicting one raises + * {@link SessionEventConflictError}. + */ +export class SessionEventCursor { + private eventsBySeq = new Map(); + private lastSeqValue = 0; + + /** Fold one raw kernel envelope into the cursor. */ + accept(raw: unknown): void { + const decoded = decodeSessionEventEnvelope(raw); + if (!decoded.ok) { + throw decoded.error; + } + const incoming = decoded.value; + const existing = this.eventsBySeq.get(incoming.seq); + if (existing) { + if (stableHash(existing) !== stableHash(incoming)) { + throw new SessionEventConflictError(incoming.seq); + } + return; + } + this.eventsBySeq.set(incoming.seq, incoming); + this.lastSeqValue = Math.max(this.lastSeqValue, incoming.seq); + } + + get lastSeq(): number { + return this.lastSeqValue; + } + + /** Cursor value for SubscribeSessionEvents(after_seq) reconnects. */ + reconnectAfterSeq(): number { + return this.lastSeqValue; + } + + /** Events safe to project into the transcript, ordered by seq. */ + displayableEvents(): SessionEventEnvelope[] { + return [...this.eventsBySeq.values()] + .filter((event) => DISPLAYABLE_FAMILIES.has(event.family)) + .sort((a, b) => a.seq - b.seq); + } +} + +export function createSessionEventCursor(): SessionEventCursor { + return new SessionEventCursor(); +} diff --git a/src/utils/session-events.js b/src/utils/session-events.js index b488910..abd0ba8 100644 --- a/src/utils/session-events.js +++ b/src/utils/session-events.js @@ -3,6 +3,37 @@ import { normalizeResponsesStreamEvent, } from './responses-stream.js'; import { isFailedToolOutput } from './tool-display.js'; +import { + RuntimeItemReducer, + sessionEventToItemOperation, +} from '../core/stream/runtime-items.ts'; + +// Identity-aware (schema v2) ingress. Canonical events carry Metadata.RuntimeItem; +// legacy assistant events use the synthesized `${invocationId}:legacy-assistant` +// identity. No identity is inferred from previous messages or body text. +export { + RuntimeItemReducer, + sessionEventToItemOperation, + legacyAssistantIdentity, + projectRuntimeItems, +} from '../core/stream/runtime-items.ts'; + +/** + * Reduce a list of session events into an identity-aware item snapshot. + * Canonical `Metadata.RuntimeItem` events keep their full identity; legacy + * `assistant_stream_snapshot`/`assistant_message` collapse onto the synthesized + * per-invocation assistant item (snapshot -> replace, message -> complete). + */ +export function buildRuntimeItemsFromSessionEvents(events = []) { + const reducer = new RuntimeItemReducer(); + for (const event of Array.isArray(events) ? events : []) { + const operation = sessionEventToItemOperation(event); + if (operation) { + reducer.apply(operation); + } + } + return reducer.snapshot(); +} /** * @typedef {import('../components/chat/types.js').Message} Message diff --git a/src/utils/session-list.js b/src/utils/session-list.js index 37920d9..2c90805 100644 --- a/src/utils/session-list.js +++ b/src/utils/session-list.js @@ -1,4 +1,23 @@ -const ACTIVE_RUN_STATUSES = new Set(['in_progress', 'running', 'queued', 'pending']); +const ACTIVE_RUN_STATUSES = new Set([ + 'in_progress', + 'running', + 'resuming', + 'starting', + 'streaming', + 'queued', + 'pending', + 'accepted', +]); +const FAILED_RUN_STATUSES = new Set([ + 'failed', + 'error', + 'resume_failed', + 'interrupted', + 'cancelled', + 'canceled', + 'aborted', + 'expired', +]); const DEFAULT_ACTIVE_STALE_AFTER_MS = 5 * 60 * 1000; function sessionUpdatedAtValue(session) { @@ -115,8 +134,10 @@ export function formatSessionContextLabel(session) { export function resolveCompactSessionMeta(session, options = {}) { const running = isSessionRunningWithOptions(session, options); + const failed = FAILED_RUN_STATUSES.has(normalizeText(session?.ActiveRunStatus)); return { running, - label: running ? '' : formatCompactUpdatedAt(session?.UpdatedAt ?? session?.updated_at), + failed, + label: running || failed ? '' : formatCompactUpdatedAt(session?.UpdatedAt ?? session?.updated_at), }; } diff --git a/tests/responses-stream.test.mjs b/tests/responses-stream.test.mjs index 4fe4810..08793b3 100644 --- a/tests/responses-stream.test.mjs +++ b/tests/responses-stream.test.mjs @@ -5,6 +5,66 @@ async function loadResponsesStreamUtils() { return import('../src/utils/responses-stream.js').catch(() => null); } +test('responses stream reducer keys message items by Responses output item id', async () => { + const responsesStream = await loadResponsesStreamUtils(); + assert.ok(responsesStream, 'expected Responses stream helpers to exist'); + assert.equal(typeof responsesStream.responsesEventToItemOperations, 'function'); + assert.equal(typeof responsesStream.RuntimeItemReducer, 'function'); + + const reducer = new responsesStream.RuntimeItemReducer(); + const runId = 'run-1'; + const scopeId = 'scope-1'; + reducer.applyAll( + responsesStream.responsesEventToItemOperations( + 'response.output_item.added', + { item: { id: 'msg_1', type: 'message' } }, + runId, + scopeId, + ), + ); + reducer.applyAll( + responsesStream.responsesEventToItemOperations( + 'response.output_text.delta', + { item_id: 'msg_1', delta: 'hel' }, + runId, + scopeId, + ), + ); + reducer.applyAll( + responsesStream.responsesEventToItemOperations( + 'response.output_item.done', + { item: { id: 'msg_1', type: 'message', content: [{ type: 'output_text', text: 'hello' }] } }, + runId, + scopeId, + ), + ); + + const snapshot = reducer.snapshot(); + assert.equal(snapshot.items.length, 1); + assert.equal(snapshot.items[0].itemId, 'msg_1'); + assert.equal(snapshot.items[0].parts[0].text, 'hello'); + assert.equal(snapshot.items[0].status, 'completed'); +}); + +test('responses stream reducer keeps identical text from distinct output item ids', async () => { + const responsesStream = await loadResponsesStreamUtils(); + assert.ok(responsesStream, 'expected Responses stream helpers to exist'); + + const reducer = new responsesStream.RuntimeItemReducer(); + for (const itemId of ['msg_1', 'msg_2']) { + reducer.applyAll( + responsesStream.responsesEventToItemOperations( + 'response.output_item.done', + { item: { id: itemId, type: 'message', content: [{ type: 'output_text', text: 'same' }] } }, + 'run-1', + 'scope-1', + ), + ); + } + const texts = responsesStream.projectRuntimeItems(reducer.snapshot()).map((item) => item.text); + assert.deepEqual(texts, ['same', 'same']); +}); + test('responses stream utils normalize native tool done and tool output items', async () => { const responsesStream = await loadResponsesStreamUtils(); @@ -114,6 +174,13 @@ test('responses stream utils expose standard mcp approval request resume metadat previousResponseId: 'resp_123', serverLabel: 'workspace', }, + { + type: 'approval_request', + approvalRequestId: 'appr_123', + previousResponseId: 'resp_123', + name: 'delete_file', + args: '{"path":"README.md"}', + }, ], ); }); @@ -144,6 +211,12 @@ test('responses stream utils project wrapped LangGraph HITL interrupts immediate status: 'paused', approvalRequestId: 'interrupt-1', previousResponseId: '', + }, { + type: 'approval_request', + approvalRequestId: 'interrupt-1', + previousResponseId: '', + name: 'write_file', + args: '{"path":"/tmp/x.txt"}', }], ); }); diff --git a/tests/session-events.test.mjs b/tests/session-events.test.mjs index 93f69c7..ef05cdd 100644 --- a/tests/session-events.test.mjs +++ b/tests/session-events.test.mjs @@ -1519,3 +1519,110 @@ test('session event utils restore accepted memory saves as completed tools', asy assert.equal(messages.length, 1); assert.equal(messages[0].tools.save_memory.status, 'completed'); }); + +test('session event reducer maps canonical Metadata.RuntimeItem by identity', async () => { + const sessionEvents = await loadSessionEventUtils(); + assert.ok(sessionEvents, 'expected session event helpers to exist'); + assert.equal(typeof sessionEvents.buildRuntimeItemsFromSessionEvents, 'function'); + + const snapshot = sessionEvents.buildRuntimeItemsFromSessionEvents([ + { + EventId: 'evt-1', + EventType: 'assistant_stream_delta', + InvocationId: 'inv-1', + Content: { role: 'model', parts: [{ text: 'hel' }] }, + Metadata: { + RuntimeItem: { + RunId: 'run-1', + ScopeId: 'scope-1', + ItemId: 'item-1', + PartId: 'text-0', + Operation: 'append', + SourceEventId: 'native-1', + }, + }, + }, + { + EventId: 'evt-2', + EventType: 'assistant_message', + InvocationId: 'inv-1', + Content: { role: 'model', parts: [{ text: 'hello' }] }, + Metadata: { + RuntimeItem: { + RunId: 'run-1', + ScopeId: 'scope-1', + ItemId: 'item-1', + PartId: 'text-0', + Operation: 'replace', + SourceEventId: 'native-2', + }, + }, + }, + ]); + + assert.equal(snapshot.items.length, 1); + assert.equal(snapshot.items[0].itemId, 'item-1'); + assert.equal(snapshot.items[0].parts[0].text, 'hello'); + assert.equal(snapshot.items[0].status, 'completed'); +}); + +test('session event reducer preserves identical text from distinct RuntimeItem ids', async () => { + const sessionEvents = await loadSessionEventUtils(); + assert.ok(sessionEvents, 'expected session event helpers to exist'); + + const makeItem = (itemId, eventId) => ({ + EventId: eventId, + EventType: 'assistant_message', + InvocationId: 'inv-1', + Content: { role: 'model', parts: [{ text: 'same' }] }, + Metadata: { + RuntimeItem: { + RunId: 'run-1', + ScopeId: 'scope-1', + ItemId: itemId, + PartId: 'text-0', + Operation: 'replace', + SourceEventId: eventId, + }, + }, + }); + const snapshot = sessionEvents.buildRuntimeItemsFromSessionEvents([ + makeItem('item-1', 'evt-1'), + makeItem('item-2', 'evt-2'), + ]); + assert.deepEqual( + snapshot.items.map((item) => item.parts[0].text), + ['same', 'same'], + ); +}); + +test('session event reducer collapses legacy snapshot then final onto one synthesized item', async () => { + const sessionEvents = await loadSessionEventUtils(); + assert.ok(sessionEvents, 'expected session event helpers to exist'); + + const snapshot = sessionEvents.buildRuntimeItemsFromSessionEvents([ + { + EventId: 'evt-snap-1', + EventType: 'assistant_stream_snapshot', + InvocationId: 'inv-1', + Content: { role: 'model', parts: [{ text: 'hel' }] }, + }, + { + EventId: 'evt-snap-2', + EventType: 'assistant_stream_snapshot', + InvocationId: 'inv-1', + Content: { role: 'model', parts: [{ text: 'hello' }] }, + }, + { + EventId: 'evt-final', + EventType: 'assistant_message', + InvocationId: 'inv-1', + Content: { role: 'model', parts: [{ text: 'hello' }] }, + }, + ]); + + assert.equal(snapshot.items.length, 1); + assert.equal(snapshot.items[0].itemId, 'inv-1:legacy-assistant'); + assert.equal(snapshot.items[0].parts[0].text, 'hello'); + assert.equal(snapshot.items[0].status, 'completed'); +}); diff --git a/tests/session-list.test.mjs b/tests/session-list.test.mjs index 14aeba5..6f20894 100644 --- a/tests/session-list.test.mjs +++ b/tests/session-list.test.mjs @@ -139,6 +139,7 @@ test('session list utils format model and context labels', async () => { assert.equal(sessionList.formatSessionContextLabel(session), '上下文 37%'); assert.equal(sessionList.isSessionRunning({ ActiveRunStatus: 'completed' }), false); assert.equal(sessionList.isSessionRunning({ ActiveRunStatus: 'in_progress' }), true); + assert.equal(sessionList.isSessionRunning({ ActiveRunStatus: 'resuming' }), true); }); test('session list utils expose compact sidebar status labels', async () => { @@ -153,6 +154,7 @@ test('session list utils expose compact sidebar status labels', async () => { }, { now }), { running: true, + failed: false, label: '', }, ); @@ -163,7 +165,19 @@ test('session list utils expose compact sidebar status labels', async () => { }), { running: false, + failed: false, label: '5月7日 16:45', }, ); + assert.deepEqual( + sessionList.resolveCompactSessionMeta({ + ActiveRunStatus: 'failed', + UpdatedAt: '2026-05-07T08:45:00Z', + }), + { + running: false, + failed: true, + label: '', + }, + ); });