Keep live Assistant workflow activity visible

Preserve workflow activity rows across active stream content and tool boundaries, and type the generated chat SSE route metadata that drives model activity rows.
This commit is contained in:
rcourtman 2026-06-07 02:41:57 +01:00
parent 6943831292
commit 59391aebf7
9 changed files with 101 additions and 24 deletions

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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',

View file

@ -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;

View file

@ -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',

View file

@ -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 || '');

View file

@ -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)

View file

@ -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,
}
],
)