Make reply use the API (#5389)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-10-28 15:27:28 -04:00
committed by GitHub
parent 35f286a9d1
commit 4034f3fcef
7 changed files with 248 additions and 142 deletions
+145 -1
View File
@@ -1247,7 +1247,14 @@
},
"responses": {
"200": {
"description": "Streaming response initiated"
"description": "Streaming response initiated",
"content": {
"text/event-stream": {
"schema": {
"$ref": "#/components/schemas/MessageEvent"
}
}
}
},
"424": {
"description": "Agent not initialized"
@@ -3172,6 +3179,143 @@
"propertyName": "type"
}
},
"MessageEvent": {
"oneOf": [
{
"type": "object",
"required": [
"message",
"type"
],
"properties": {
"message": {
"$ref": "#/components/schemas/Message"
},
"type": {
"type": "string",
"enum": [
"Message"
]
}
}
},
{
"type": "object",
"required": [
"error",
"type"
],
"properties": {
"error": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"Error"
]
}
}
},
{
"type": "object",
"required": [
"reason",
"type"
],
"properties": {
"reason": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"Finish"
]
}
}
},
{
"type": "object",
"required": [
"model",
"mode",
"type"
],
"properties": {
"mode": {
"type": "string"
},
"model": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"ModelChange"
]
}
}
},
{
"type": "object",
"required": [
"request_id",
"message",
"type"
],
"properties": {
"message": {
"type": "object"
},
"request_id": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"Notification"
]
}
}
},
{
"type": "object",
"required": [
"conversation",
"type"
],
"properties": {
"conversation": {
"$ref": "#/components/schemas/Conversation"
},
"type": {
"type": "string",
"enum": [
"UpdateConversation"
]
}
}
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"Ping"
]
}
}
}
],
"discriminator": {
"propertyName": "type"
}
},
"MessageMetadata": {
"type": "object",
"description": "Metadata for message visibility",
+1 -1
View File
@@ -351,7 +351,7 @@ export const scanRecipe = <ThrowOnError extends boolean = false>(options: Option
};
export const reply = <ThrowOnError extends boolean = false>(options: Options<ReplyData, ThrowOnError>) => {
return (options.client ?? client).post<ReplyResponses, ReplyErrors, ThrowOnError>({
return (options.client ?? client).sse.post<ReplyResponses, ReplyErrors, ThrowOnError>({
url: '/reply',
...options,
headers: {
+29 -1
View File
@@ -360,6 +360,32 @@ export type MessageContent = (TextContent & {
type: 'systemNotification';
});
export type MessageEvent = {
message: Message;
type: 'Message';
} | {
error: string;
type: 'Error';
} | {
reason: string;
type: 'Finish';
} | {
mode: string;
model: string;
type: 'ModelChange';
} | {
message: {
[key: string]: unknown;
};
request_id: string;
type: 'Notification';
} | {
conversation: Conversation;
type: 'UpdateConversation';
} | {
type: 'Ping';
};
/**
* Metadata for message visibility
*/
@@ -1825,9 +1851,11 @@ export type ReplyResponses = {
/**
* Streaming response initiated
*/
200: unknown;
200: MessageEvent;
};
export type ReplyResponse = ReplyResponses[keyof ReplyResponses];
export type CreateScheduleData = {
body: CreateScheduleRequest;
path?: never;
+65 -136
View File
@@ -1,17 +1,18 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChatState } from '../types/chatState';
import {
Conversation,
Message,
MessageEvent,
reply,
resumeAgent,
Session,
updateFromSession,
updateSessionUserRecipeValues,
} from '../api';
import { getApiUrl } from '../config';
import { createUserMessage, getCompactingMessage, getThinkingMessage } from '../types/message';
const TextDecoder = globalThis.TextDecoder;
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
// Debug logging - set to false in production
@@ -45,28 +46,6 @@ const log = {
},
};
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
interface NotificationEvent {
type: 'Notification';
request_id: string;
message: {
method: string;
params: {
[key: string]: JsonValue;
};
};
}
type MessageEvent =
| { type: 'Message'; message: Message }
| { type: 'Error'; error: string }
| { type: 'Ping' }
| { type: 'Finish'; reason: string }
| { type: 'ModelChange'; model: string; mode: string }
| { type: 'UpdateConversation'; conversation: Conversation }
| NotificationEvent;
interface UseChatStreamProps {
sessionId: string;
onStreamFinish: () => void;
@@ -106,121 +85,78 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
}
async function streamFromResponse(
response: Response,
stream: AsyncIterable<MessageEvent>,
initialMessages: Message[],
updateMessages: (messages: Message[]) => void,
updateChatState: (state: ChatState) => void,
onFinish: (error?: string) => void
): Promise<void> {
let chunkCount = 0;
let messageEventCount = 0;
let currentMessages = initialMessages;
try {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error('No response body');
log.stream('reading-events');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let currentMessages = initialMessages;
for await (const event of stream) {
switch (event.type) {
case 'Message': {
messageEventCount++;
const msg = event.message;
currentMessages = pushMessage(currentMessages, msg);
log.stream('reading-chunks');
while (true) {
const { done, value } = await reader.read();
if (done) {
log.stream('chunks-complete', {
totalChunks: chunkCount,
messageEvents: messageEventCount,
});
break;
}
chunkCount++;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const event = JSON.parse(data) as MessageEvent;
switch (event.type) {
case 'Message': {
messageEventCount++;
const msg = event.message;
currentMessages = pushMessage(currentMessages, msg);
if (getCompactingMessage(msg)) {
log.state(ChatState.Compacting, { reason: 'compacting notification' });
updateChatState(ChatState.Compacting);
} else if (getThinkingMessage(msg)) {
log.state(ChatState.Thinking, { reason: 'thinking notification' });
updateChatState(ChatState.Thinking);
}
// Only log every 10th message event to avoid spam
if (messageEventCount % 10 === 0) {
log.stream('message-chunk', {
eventCount: messageEventCount,
messageCount: currentMessages.length,
});
}
// This calls the wrapped setMessagesAndLog with 'streaming' context
updateMessages(currentMessages);
break;
}
case 'Error': {
log.error('stream event error', event.error);
onFinish('Stream error: ' + event.error);
return;
}
case 'Finish': {
log.stream('finish-event', { reason: event.reason });
onFinish();
return;
}
case 'ModelChange': {
log.stream('model-change', {
model: event.model,
mode: event.mode,
});
break;
}
case 'UpdateConversation': {
log.messages('conversation-update', event.conversation.length);
currentMessages = event.conversation;
// This calls the wrapped setMessagesAndLog with 'streaming' context
updateMessages(event.conversation);
break;
}
case 'Notification': {
// Don't log notifications, too noisy
break;
}
case 'Ping': {
// Don't log pings
break;
}
default: {
console.warn('Unhandled event type:', event['type']);
break;
}
if (getCompactingMessage(msg)) {
log.state(ChatState.Compacting, { reason: 'compacting notification' });
updateChatState(ChatState.Compacting);
} else if (getThinkingMessage(msg)) {
log.state(ChatState.Thinking, { reason: 'thinking notification' });
updateChatState(ChatState.Thinking);
}
} catch (e) {
log.error('SSE parse failed', e);
onFinish('Failed to parse SSE:' + e);
if (messageEventCount % 10 === 0) {
log.stream('message-chunk', {
eventCount: messageEventCount,
messageCount: currentMessages.length,
});
}
updateMessages(currentMessages);
break;
}
case 'Error': {
log.error('stream event error', event.error);
onFinish('Stream error: ' + event.error);
return;
}
case 'Finish': {
log.stream('finish-event', { reason: event.reason });
onFinish();
return;
}
case 'ModelChange': {
log.stream('model-change', {
model: event.model,
mode: event.mode,
});
break;
}
case 'UpdateConversation': {
log.messages('conversation-update', event.conversation.length);
currentMessages = event.conversation;
updateMessages(event.conversation);
break;
}
case 'Notification':
case 'Ping':
break;
}
}
log.stream('events-complete', { messageEvents: messageEventCount });
onFinish();
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
log.error('stream read error', error);
onFinish('Stream error:' + error);
onFinish('Stream error: ' + error);
}
}
}
@@ -337,26 +273,19 @@ export function useChatStream({
try {
log.stream('request-start', { sessionId: sessionId.slice(0, 8) });
const response = await fetch(getApiUrl('/reply'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': await window.electron.getSecretKey(),
},
body: JSON.stringify({
const { stream } = await reply({
body: {
session_id: sessionId,
messages: currentMessages,
}),
},
throwOnError: true,
signal: abortControllerRef.current.signal,
});
log.stream('response-received', {
status: response.status,
ok: response.ok,
});
log.stream('stream-started');
await streamFromResponse(
response,
stream,
currentMessages,
(messages: Message[]) => setMessagesAndLog(messages, 'streaming'),
setChatState,