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
+2 -2
View File
@@ -27,6 +27,7 @@ use goose_bench::runners::metric_aggregator::MetricAggregator;
use goose_bench::runners::model_runner::ModelRunner; use goose_bench::runners::model_runner::ModelRunner;
use std::io::Read; use std::io::Read;
use std::path::PathBuf; use std::path::PathBuf;
use tracing::warn;
#[derive(Parser)] #[derive(Parser)]
#[command(author, version, display_name = "", about, long_about = None)] #[command(author, version, display_name = "", about, long_about = None)]
@@ -820,9 +821,8 @@ pub struct RecipeInfo {
pub async fn cli() -> Result<()> { pub async fn cli() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
// Track the current directory in projects.json
if let Err(e) = crate::project_tracker::update_project_tracker(None, None) { if let Err(e) = crate::project_tracker::update_project_tracker(None, None) {
eprintln!("Warning: Failed to update project tracker: {}", e); warn!("Warning: Failed to update project tracker: {}", e);
} }
let command_name = match &cli.command { let command_name = match &cli.command {
+2
View File
@@ -23,6 +23,7 @@ use goose::conversation::message::{
ToolRequest, ToolResponse, ToolRequest, ToolResponse,
}; };
use crate::routes::reply::MessageEvent;
use utoipa::openapi::schema::{ use utoipa::openapi::schema::{
AdditionalProperties, AnyOfBuilder, ArrayBuilder, ObjectBuilder, OneOfBuilder, Schema, AdditionalProperties, AnyOfBuilder, ArrayBuilder, ObjectBuilder, OneOfBuilder, Schema,
SchemaFormat, SchemaType, SchemaFormat, SchemaType,
@@ -420,6 +421,7 @@ derive_utoipa!(Icon as IconSchema);
ResourceContentsSchema, ResourceContentsSchema,
SystemNotificationType, SystemNotificationType,
SystemNotificationContent, SystemNotificationContent,
MessageEvent,
JsonObjectSchema, JsonObjectSchema,
RoleSchema, RoleSchema,
ProviderMetadata, ProviderMetadata,
+4 -1
View File
@@ -139,6 +139,7 @@ pub enum MessageEvent {
}, },
Notification { Notification {
request_id: String, request_id: String,
#[schema(value_type = Object)]
message: ServerNotification, message: ServerNotification,
}, },
UpdateConversation { UpdateConversation {
@@ -170,7 +171,9 @@ async fn stream_event(
path = "/reply", path = "/reply",
request_body = ChatRequest, request_body = ChatRequest,
responses( responses(
(status = 200, description = "Streaming response initiated", content_type = "text/event-stream"), (status = 200, description = "Streaming response initiated",
body = MessageEvent,
content_type = "text/event-stream"),
(status = 424, description = "Agent not initialized"), (status = 424, description = "Agent not initialized"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error")
) )
+145 -1
View File
@@ -1247,7 +1247,14 @@
}, },
"responses": { "responses": {
"200": { "200": {
"description": "Streaming response initiated" "description": "Streaming response initiated",
"content": {
"text/event-stream": {
"schema": {
"$ref": "#/components/schemas/MessageEvent"
}
}
}
}, },
"424": { "424": {
"description": "Agent not initialized" "description": "Agent not initialized"
@@ -3172,6 +3179,143 @@
"propertyName": "type" "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": { "MessageMetadata": {
"type": "object", "type": "object",
"description": "Metadata for message visibility", "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>) => { 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', url: '/reply',
...options, ...options,
headers: { headers: {
+29 -1
View File
@@ -360,6 +360,32 @@ export type MessageContent = (TextContent & {
type: 'systemNotification'; 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 * Metadata for message visibility
*/ */
@@ -1825,9 +1851,11 @@ export type ReplyResponses = {
/** /**
* Streaming response initiated * Streaming response initiated
*/ */
200: unknown; 200: MessageEvent;
}; };
export type ReplyResponse = ReplyResponses[keyof ReplyResponses];
export type CreateScheduleData = { export type CreateScheduleData = {
body: CreateScheduleRequest; body: CreateScheduleRequest;
path?: never; path?: never;
+65 -136
View File
@@ -1,17 +1,18 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ChatState } from '../types/chatState'; import { ChatState } from '../types/chatState';
import { import {
Conversation,
Message, Message,
MessageEvent,
reply,
resumeAgent, resumeAgent,
Session, Session,
updateFromSession, updateFromSession,
updateSessionUserRecipeValues, updateSessionUserRecipeValues,
} from '../api'; } from '../api';
import { getApiUrl } from '../config';
import { createUserMessage, getCompactingMessage, getThinkingMessage } from '../types/message'; import { createUserMessage, getCompactingMessage, getThinkingMessage } from '../types/message';
const TextDecoder = globalThis.TextDecoder;
const resultsCache = new Map<string, { messages: Message[]; session: Session }>(); const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
// Debug logging - set to false in production // 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 { interface UseChatStreamProps {
sessionId: string; sessionId: string;
onStreamFinish: () => void; onStreamFinish: () => void;
@@ -106,121 +85,78 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
} }
async function streamFromResponse( async function streamFromResponse(
response: Response, stream: AsyncIterable<MessageEvent>,
initialMessages: Message[], initialMessages: Message[],
updateMessages: (messages: Message[]) => void, updateMessages: (messages: Message[]) => void,
updateChatState: (state: ChatState) => void, updateChatState: (state: ChatState) => void,
onFinish: (error?: string) => void onFinish: (error?: string) => void
): Promise<void> { ): Promise<void> {
let chunkCount = 0;
let messageEventCount = 0; let messageEventCount = 0;
let currentMessages = initialMessages;
try { try {
if (!response.ok) throw new Error(`HTTP ${response.status}`); log.stream('reading-events');
if (!response.body) throw new Error('No response body');
const reader = response.body.getReader(); for await (const event of stream) {
const decoder = new TextDecoder(); switch (event.type) {
let currentMessages = initialMessages; case 'Message': {
messageEventCount++;
const msg = event.message;
currentMessages = pushMessage(currentMessages, msg);
log.stream('reading-chunks'); if (getCompactingMessage(msg)) {
log.state(ChatState.Compacting, { reason: 'compacting notification' });
while (true) { updateChatState(ChatState.Compacting);
const { done, value } = await reader.read(); } else if (getThinkingMessage(msg)) {
if (done) { log.state(ChatState.Thinking, { reason: 'thinking notification' });
log.stream('chunks-complete', { updateChatState(ChatState.Thinking);
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;
}
} }
} catch (e) {
log.error('SSE parse failed', e); if (messageEventCount % 10 === 0) {
onFinish('Failed to parse SSE:' + e); 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) { } catch (error) {
if (error instanceof Error && error.name !== 'AbortError') { if (error instanceof Error && error.name !== 'AbortError') {
log.error('stream read error', error); log.error('stream read error', error);
onFinish('Stream error:' + error); onFinish('Stream error: ' + error);
} }
} }
} }
@@ -337,26 +273,19 @@ export function useChatStream({
try { try {
log.stream('request-start', { sessionId: sessionId.slice(0, 8) }); log.stream('request-start', { sessionId: sessionId.slice(0, 8) });
const response = await fetch(getApiUrl('/reply'), { const { stream } = await reply({
method: 'POST', body: {
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': await window.electron.getSecretKey(),
},
body: JSON.stringify({
session_id: sessionId, session_id: sessionId,
messages: currentMessages, messages: currentMessages,
}), },
throwOnError: true,
signal: abortControllerRef.current.signal, signal: abortControllerRef.current.signal,
}); });
log.stream('response-received', { log.stream('stream-started');
status: response.status,
ok: response.ok,
});
await streamFromResponse( await streamFromResponse(
response, stream,
currentMessages, currentMessages,
(messages: Message[]) => setMessagesAndLog(messages, 'streaming'), (messages: Message[]) => setMessagesAndLog(messages, 'streaming'),
setChatState, setChatState,