Phase 1 run-control semantics and run_state events

This commit is contained in:
William Valentin
2026-02-25 10:22:44 -08:00
parent ae21681958
commit e4ee6acce8
13 changed files with 485 additions and 120 deletions
+21
View File
@@ -715,6 +715,10 @@ async function sendMessage(client, overrideText) {
scrollToBottom();
// Create placeholder for assistant response
const statusLine = document.createElement('div');
statusLine.className = 'px-1 text-[11px] leading-none text-zinc-500 select-none hidden';
statusLine.textContent = 'Run status: queued';
_elements.messages.appendChild(statusLine);
const placeholder = document.createElement('div');
placeholder.className = 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-zinc-900 border border-zinc-800 text-zinc-50 streaming-cursor';
placeholder.innerHTML = '<span class="text-zinc-500">Thinking...</span>';
@@ -759,6 +763,22 @@ async function sendMessage(client, overrideText) {
scrollToBottom();
});
stream.on('run_state', (data) => {
if (!data || !data.state) {
return;
}
const labels = {
start: 'Run status: working',
cancel_requested: 'Run status: cancellation requested',
cancelled: 'Run status: cancelled',
complete: 'Run status: complete',
error: `Run status: error${data.message ? ` (${data.message})` : ''}`,
};
statusLine.textContent = labels[data.state] || `Run status: ${data.state}`;
statusLine.classList.remove('hidden');
scrollToBottom();
});
const done = await stream.result;
const content = done?.content ?? done?.text ?? '(no response)';
const assistantMessage = createMessageEl('assistant', content, Date.now());
@@ -771,6 +791,7 @@ async function sendMessage(client, overrideText) {
_cancelling = false;
updateSendButton();
clearPendingAttachments();
statusLine.remove();
scrollToBottom();
}
}
+51
View File
@@ -185,4 +185,55 @@ describe('ChatPage wiring', () => {
expect(calls.some((entry) => entry.method === 'agent.cancel')).toBe(true);
});
it('renders run_state updates during streaming', async () => {
let resolveResult: ((value: { content: string }) => void) | undefined;
const handlers = new Map<string, Array<(data: any) => void>>();
const stream = {
on(event: string, cb: (data: any) => void) {
if (!handlers.has(event)) {
handlers.set(event, []);
}
handlers.get(event)?.push(cb);
},
emit(event: string, data: any) {
for (const cb of handlers.get(event) ?? []) {
cb(data);
}
},
result: new Promise<{ content: string }>((resolve) => {
resolveResult = resolve;
}),
};
const client = {
async call(method: string) {
if (method === 'sessions.list') {
return { sessions: [] };
}
return null;
},
stream() {
return stream;
},
};
await ChatPage.render(root, client);
const input = root.querySelector('#chat-input');
input.value = 'hello';
root.querySelector('#chat-send').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
stream.emit('run_state', { state: 'start' });
await Promise.resolve();
const statusLine = Array.from(root.querySelectorAll('div'))
.find((el: any) => String(el.textContent ?? '').startsWith('Run status:'));
expect(statusLine).toBeTruthy();
expect(statusLine.classList.contains('hidden')).toBe(false);
resolveResult?.({ content: 'ok' });
await Promise.resolve();
});
});