diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 7f098686c..55ef664cc 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -170,6 +170,26 @@ runtime cost control, and shared AI transport surfaces. that by carrying the stream event start/end timestamps into the completed browser tool row, so the visible activity timeline does not collapse when a running command is replaced by its completed result. + Live workflow activity is also active turn state, not disposable waiting + copy. The referenced OpenCode source at fetched `origin/dev` commit + `4519a1da329c1a4fc384054e7203ba7d06928205` defines model, shell, step, + and tool lifecycle events in `packages/core/src/session/event.ts` (model + switched lines 62-70; shell started/ended lines 151-174; tool lifecycle + lines 340-392), projects them into durable message/part rows in + `packages/core/src/session/message-updater.ts` (step started lines + 189-210; tool called/progress/success/failed lines 269-335), and renders + model-switch plus tool rows in + `packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx` + (model switch lines 120-122 and 262-274; tool rows lines 455-500). + Pulse adapts that by keeping the latest browser `workflow_status` activity + visible in the live stream event sequence across content, tool, approval, + and question boundaries instead of dropping it the instant a richer row + arrives. Burst workflow statuses may still replace one another until a + durable stream boundary appears, and terminal cleanup on done/error/Stop + may remove transient workflow-status rows while preserving typed + model-switch and tool evidence. The generated frontend SSE contract must + include workflow `provider` and `model` fields so selected-route activity + is typed end to end. Composer prompt history is also drawer-local chat-runtime state: the drawer may persist a bounded local history of submitted prompt text and structured mentions for ArrowUp/ArrowDown recall, but that history must not persist or diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 1317ebae5..2a41e91cb 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -108,8 +108,10 @@ product API routes free of maintainer commercial analytics. 68. `frontend-modern/src/api/ai.ts` 69. `frontend-modern/src/api/aiChat.ts` 70. `frontend-modern/src/api/patrol.ts` -71. `internal/api/agent_exec_token_binding.go` -72. `internal/agentcontext/` +71. `frontend-modern/src/api/generated/aiChatEvents.ts` +72. `internal/api/agent_exec_token_binding.go` +73. `internal/agentcontext/` +74. `scripts/generate-types.go` ## Shared Boundaries @@ -1007,10 +1009,11 @@ the canonical monitored-system blocked payload. 9. Route frontend API-client parsed error propagation, API-error-status fallback handling, allowed-status handling, custom status-specific error handling, command-trigger success envelope handling, shared response parsing pipelines, missing-resource lookup handling, metadata CRUD routing, stream event consumption, response status, collection normalization, scalar payload coercion, and structured error normalization through canonical shared helpers under `frontend-modern/src/api/` Assistant chat stream workflow-state payloads are part of this same frontend API-client boundary. `workflow_state` events must keep `phase`, - `message`, `state`, and `tool` stable, and provider fallback transitions may - additionally carry `failed_provider`, `failed_model`, `next_provider`, and - `next_model` so diagnostics can identify the exact route change without - expanding the visible operator message. The generated + `message`, `state`, and `tool` stable, selected-route starts may carry + `provider` and `model`, and provider fallback transitions may additionally + carry `failed_provider`, `failed_model`, `next_provider`, and `next_model` so + diagnostics can identify the exact route change without expanding the visible + operator message. The generated `frontend-modern/src/api/generated/aiChatEvents.ts` type must stay derived from `internal/ai/chat/types.go` through `scripts/generate-types.go`, and frontend API tests must pin any new generated SSE fields, including live diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index b88673ca7..f25615ac9 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1562,7 +1562,8 @@ "pkg/pulsecli/actions.go", "pkg/pulsecli/api_client.go", "pkg/pulsecli/fleet.go", - "pkg/pulsecli/root.go" + "pkg/pulsecli/root.go", + "scripts/generate-types.go" ], "verification": { "allow_same_subsystem_tests": false, @@ -1574,6 +1575,21 @@ ], "require_explicit_path_policy_coverage": true, "path_policies": [ + { + "id": "ai-chat-stream-event-types", + "label": "AI chat SSE generated type proof", + "match_prefixes": [], + "match_files": [ + "frontend-modern/src/api/generated/aiChatEvents.ts", + "scripts/generate-types.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/api/__tests__/aiChatEvents.test.ts", + "internal/api/contract_test.go" + ] + }, { "id": "auth-state-persistence-compatibility", "label": "session and CSRF persistence migration proof", diff --git a/frontend-modern/src/api/__tests__/aiChatEvents.test.ts b/frontend-modern/src/api/__tests__/aiChatEvents.test.ts index 011968862..f1fa0f889 100644 --- a/frontend-modern/src/api/__tests__/aiChatEvents.test.ts +++ b/frontend-modern/src/api/__tests__/aiChatEvents.test.ts @@ -42,6 +42,21 @@ describe('AI chat stream event contract', () => { expect(aiChatEventsSource).toContain('next_model?: string'); }); + it('exposes selected provider and model metadata on workflow state events', () => { + const workflow: WorkflowStateData = { + phase: 'provider_start', + message: 'Sent request to OpenRouter; waiting for the first token.', + provider: 'openrouter', + model: 'openrouter:qwen/qwen3.7-plus', + }; + const event: AIChatStreamEvent = { type: 'workflow_state', data: workflow }; + + expect(event.data.provider).toBe('openrouter'); + expect(event.data.model).toBe('openrouter:qwen/qwen3.7-plus'); + expect(aiChatEventsSource).toContain('provider?: string'); + expect(aiChatEventsSource).toContain('model?: string'); + }); + it('exposes provider retry metadata on workflow state events', () => { const workflow: WorkflowStateData = { phase: 'provider_retry', diff --git a/frontend-modern/src/api/generated/aiChatEvents.ts b/frontend-modern/src/api/generated/aiChatEvents.ts index 17cff18af..6ae80d294 100644 --- a/frontend-modern/src/api/generated/aiChatEvents.ts +++ b/frontend-modern/src/api/generated/aiChatEvents.ts @@ -128,6 +128,8 @@ export interface WorkflowStateData { message: string; state?: string; tool?: string; + provider?: string; + model?: string; attempt?: number; max_attempts?: number; retry_after_ms?: number; diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index cab85f48d..e742ee4a7 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -1481,7 +1481,7 @@ describe('useChat', () => { dispose(); }); - it('clears neutral workflow progress when answer content starts streaming', async () => { + it('keeps neutral workflow activity visible when answer content starts streaming', async () => { const { getFireEvent } = setupWithEventCapture(); const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); @@ -1500,11 +1500,23 @@ describe('useChat', () => { const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.content).toBe('Here is the answer.'); expect(assistant.workflowStatus).toBeUndefined(); - expect(assistant.streamEvents?.map((event) => event.type)).toEqual(['content']); + expect(assistant.streamEvents?.map((event) => event.type)).toEqual([ + 'workflow_status', + 'content', + ]); + expect(assistant.streamEvents?.[0]).toEqual( + expect.objectContaining({ + type: 'workflow_status', + workflowStatus: expect.objectContaining({ + phase: 'provider_start', + message: 'Sent request to OpenRouter; waiting for the first token.', + }), + }), + ); dispose(); }); - it('clears neutral workflow progress when a governed tool block starts', async () => { + it('keeps neutral workflow activity visible when a governed tool block starts', async () => { const { getFireEvent } = setupWithEventCapture(); const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); @@ -1523,7 +1535,19 @@ describe('useChat', () => { const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.workflowStatus).toBeUndefined(); expect(assistant.pendingTools).toHaveLength(1); - expect(assistant.streamEvents?.map((event) => event.type)).toEqual(['pending_tool']); + expect(assistant.streamEvents?.map((event) => event.type)).toEqual([ + 'workflow_status', + 'pending_tool', + ]); + expect(assistant.streamEvents?.[0]).toEqual( + expect.objectContaining({ + type: 'workflow_status', + workflowStatus: expect.objectContaining({ + phase: 'context', + message: 'Reading Pulse inventory.', + }), + }), + ); dispose(); }); @@ -1568,10 +1592,11 @@ describe('useChat', () => { }), ); expect(assistant.streamEvents?.map((event) => event.type)).toEqual([ + 'workflow_status', 'tool', 'workflow_status', ]); - expect(assistant.streamEvents?.[1]).toEqual( + expect(assistant.streamEvents?.[2]).toEqual( expect.objectContaining({ workflowStatus: expect.objectContaining({ phase: 'model_thinking', diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 91e25297e..63483c138 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -313,10 +313,7 @@ export function useChat(options: UseChatOptions = {}) { const addStreamEvent = (msg: ChatMessage, event: StreamDisplayEvent): ChatMessage => { const nextEvent = withStreamEventTiming(event); - const events = - nextEvent.type === 'workflow_status' - ? msg.streamEvents || [] - : streamEventsWithoutWorkflowStatusRows(msg.streamEvents || []) || []; + const events = msg.streamEvents || []; // For content events, merge consecutive content into one if (nextEvent.type === 'content' && events.length > 0) { @@ -574,7 +571,7 @@ export function useChat(options: UseChatOptions = {}) { return { ...msg, streamEvents: replacePendingToolStreamEvents( - streamEventsWithoutWorkflowStatusRows(msg.streamEvents || []) || [], + msg.streamEvents || [], resolvedTool, matchesTool, now, @@ -655,7 +652,7 @@ export function useChat(options: UseChatOptions = {}) { ...msg, streamEvents: resolvedTool ? replacePendingToolStreamEvents( - streamEventsWithoutWorkflowStatusRows(msg.streamEvents || []) || [], + msg.streamEvents || [], resolvedTool, matchesTool, now, @@ -1211,7 +1208,7 @@ export function useChat(options: UseChatOptions = {}) { success: boolean; }; const pendingTools = msg.pendingTools || []; - const events = streamEventsWithoutWorkflowStatusRows(msg.streamEvents || []) || []; + const events = msg.streamEvents || []; const normalizedEndName = normalizeChatToolName(data.name || ''); diff --git a/scripts/generate-types.go b/scripts/generate-types.go index 1c8c7b616..f01f94e12 100644 --- a/scripts/generate-types.go +++ b/scripts/generate-types.go @@ -86,10 +86,9 @@ func main() { // QuestionData is wrapped by the backend as {question_id, questions} plus session_id in some callers. // The contract test covers {question_id, questions}; the UI currently expects session_id too. // Keep session_id optional for backward compatibility. - buf.WriteString(" | { type: 'question'; data: (QuestionData & { session_id?: string }) }\n") + buf.WriteString(" | { type: 'question'; data: QuestionData & { session_id?: string } }\n") buf.WriteString(" | { type: 'done'; data?: DoneData }\n") - buf.WriteString(" | { type: 'error'; data: ErrorData }\n") - buf.WriteString(";\n") + buf.WriteString(" | { type: 'error'; data: ErrorData };\n") if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { fatal(err) diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 0803a0a27..0170b8193 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2860,8 +2860,8 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 388, - "heading_line": 114, + "line": 390, + "heading_line": 116, } ], )