mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
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:
parent
6943831292
commit
59391aebf7
9 changed files with 101 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 || '');
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue