feat: use acp for desktop chat prompt (feature toggle off) (#9802)

Signed-off-by: Douwe M Osinga <douwe@sidewalklabs.com>
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Lifei Zhou
2026-06-18 10:13:08 +10:00
committed by GitHub
parent 109bda35ac
commit 802dd39165
23 changed files with 2963 additions and 44 deletions
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { parseAcpCreditsExhaustedError } from '../errors';
describe('parseAcpCreditsExhaustedError', () => {
it('parses structured ACP credits exhausted errors', () => {
expect(
parseAcpCreditsExhaustedError({
code: -32603,
message: 'Please add credits to your account, then resend your message to continue.',
data: {
reason: 'credits_exhausted',
url: 'https://router.tetrate.ai/billing',
},
})
).toEqual({
message: 'Please add credits to your account, then resend your message to continue.',
url: 'https://router.tetrate.ai/billing',
});
});
it('parses wrapped JSON-RPC errors', () => {
expect(
parseAcpCreditsExhaustedError({
error: {
code: -32603,
message: 'Add credits to continue.',
data: {
reason: 'credits_exhausted',
},
},
})
).toEqual({
message: 'Add credits to continue.',
});
});
it('ignores non-credits-exhausted errors', () => {
expect(
parseAcpCreditsExhaustedError({
code: -32603,
message: 'Something failed.',
data: {
reason: 'provider_error',
},
})
).toBeNull();
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import type { Message } from '../../api';
import { messageToAcpPromptContent } from '../prompt';
describe('messageToAcpPromptContent', () => {
it('converts text and image content into ACP prompt blocks', () => {
const message: Message = {
id: 'message-1',
role: 'user',
created: 123,
content: [
{ type: 'text', text: 'Describe this' },
{ type: 'image', data: 'abc123', mimeType: 'image/png' },
],
metadata: { userVisible: true, agentVisible: true },
};
expect(messageToAcpPromptContent(message)).toEqual([
{ type: 'text', text: 'Describe this' },
{ type: 'image', data: 'abc123', mimeType: 'image/png' },
]);
});
it('omits empty text content and unsupported content blocks', () => {
const message: Message = {
id: 'message-1',
role: 'user',
created: 123,
content: [
{ type: 'text', text: ' ' },
{
type: 'toolResponse',
id: 'tool-1',
toolResult: { status: 'success', value: [] },
},
],
metadata: { userVisible: true, agentVisible: true },
} as Message;
expect(messageToAcpPromptContent(message)).toEqual([]);
});
});
@@ -0,0 +1,406 @@
import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk';
import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk';
import { describe, expect, it } from 'vitest';
import type { Message, MessageContent } from '../../api';
import {
createAcpSessionNotificationAdapter,
type AcpChatStateChange,
} from '../sessionNotificationAdapter';
const SESSION_ID = 'session-1';
function acpUpdate(update: SessionNotification['update']): SessionNotification {
return {
sessionId: SESSION_ID,
update,
};
}
function gooseUpdate(
update: GooseSessionNotification_unstable['update']
): GooseSessionNotification_unstable {
return {
sessionId: SESSION_ID,
update,
};
}
function agentText(text: string): SessionNotification {
return acpUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
});
}
function userText(text: string): SessionNotification {
return acpUpdate({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text },
});
}
function agentThought(text: string): SessionNotification {
return acpUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text },
});
}
function agentImage(data: string, mimeType: string): SessionNotification {
return acpUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'image', data, mimeType },
});
}
function expectOnlyMessagesChange(chatStateChanges: AcpChatStateChange[]): Message[] {
expect(chatStateChanges).toHaveLength(1);
const [chatStateChange] = chatStateChanges;
expect(chatStateChange.type).toBe('messages');
if (chatStateChange.type !== 'messages') {
throw new Error('expected messages state change');
}
return chatStateChange.messages;
}
function firstContent(message: Message): MessageContent {
const content = message.content[0];
expect(content).toBeDefined();
return content;
}
describe('createAcpSessionNotificationAdapter', () => {
describe('apply', () => {
describe('message chunks', () => {
it('maps and merges text chunks by role', () => {
const adapter = createAcpSessionNotificationAdapter();
adapter.apply(agentText('Hello '));
const secondChunkStateChanges = adapter.apply(agentText('world'));
let messages = expectOnlyMessagesChange(secondChunkStateChanges);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe('assistant');
expect(firstContent(messages[0])).toMatchObject({ type: 'text', text: 'Hello world' });
const userTextStateChanges = adapter.apply(userText('Question'));
messages = expectOnlyMessagesChange(userTextStateChanges);
expect(messages).toHaveLength(2);
expect(messages[1].role).toBe('user');
expect(firstContent(messages[1])).toMatchObject({ type: 'text', text: 'Question' });
});
it('appends repeated adjacent text deltas', () => {
const adapter = createAcpSessionNotificationAdapter();
adapter.apply(agentText('Hel'));
const messages = expectOnlyMessagesChange(adapter.apply(agentText('l')));
expect(messages).toHaveLength(1);
expect(firstContent(messages[0])).toMatchObject({ type: 'text', text: 'Hell' });
});
it('maps image and thinking chunks to existing message content shapes', () => {
const imageAdapter = createAcpSessionNotificationAdapter();
const imageStateChanges = imageAdapter.apply(agentImage('base64-image', 'image/png'));
const imageMessages = expectOnlyMessagesChange(imageStateChanges);
expect(firstContent(imageMessages[0])).toMatchObject({
type: 'image',
data: 'base64-image',
mimeType: 'image/png',
});
const thoughtAdapter = createAcpSessionNotificationAdapter();
thoughtAdapter.apply(agentThought('Thinking '));
const thoughtStateChanges = thoughtAdapter.apply(agentThought('more'));
const thoughtMessages = expectOnlyMessagesChange(thoughtStateChanges);
expect(thoughtMessages).toHaveLength(1);
expect(firstContent(thoughtMessages[0])).toMatchObject({
type: 'thinking',
thinking: 'Thinking more',
signature: '',
});
});
});
describe('tools', () => {
it('maps tool calls and successful responses, including MCP app metadata', () => {
const adapter = createAcpSessionNotificationAdapter();
const toolCallStateChanges = adapter.apply(
acpUpdate({
sessionUpdate: 'tool_call',
toolCallId: 'tool-1',
title: 'Read file',
kind: 'read',
status: 'in_progress',
rawInput: { path: 'README.md' },
locations: [{ path: 'README.md', line: 1 }],
_meta: {
goose: {
toolCall: {
extensionName: 'developer',
toolName: 'read_file',
},
},
},
})
);
let messages = expectOnlyMessagesChange(toolCallStateChanges);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe('assistant');
expect(firstContent(messages[0])).toMatchObject({
type: 'toolRequest',
id: 'tool-1',
toolCall: {
status: 'success',
value: {
name: 'read_file',
arguments: { path: 'README.md' },
},
},
metadata: {
title: 'Read file',
status: 'in_progress',
extensionName: 'developer',
kind: 'read',
locations: [{ path: 'README.md', line: 1 }],
},
});
const toolResponseStateChanges = adapter.apply(
acpUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: 'tool-1',
status: 'completed',
rawOutput: 'raw result',
content: [
{
type: 'content',
content: { type: 'text', text: 'rendered result' },
},
],
_meta: {
goose: {
mcpApp: {
resourceUri: 'ui://app/resource',
extensionName: 'developer',
toolName: 'read_file',
},
},
},
})
);
messages = expectOnlyMessagesChange(toolResponseStateChanges);
expect(messages).toHaveLength(2);
expect(messages[1].role).toBe('user');
expect(firstContent(messages[1])).toMatchObject({
type: 'toolResponse',
id: 'tool-1',
toolResult: {
status: 'success',
value: {
content: [{ type: 'text', text: 'rendered result' }],
isError: false,
_meta: {
ui: { resourceUri: 'ui://app/resource' },
extensionName: 'developer',
toolName: 'read_file',
},
},
},
metadata: {
status: 'completed',
rawOutput: 'raw result',
},
});
});
it('maps failed tool responses to error results', () => {
const adapter = createAcpSessionNotificationAdapter();
const failedToolStateChanges = adapter.apply(
acpUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: 'tool-1',
status: 'failed',
title: 'Read file',
rawOutput: 'permission denied',
})
);
const messages = expectOnlyMessagesChange(failedToolStateChanges);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe('user');
expect(firstContent(messages[0])).toMatchObject({
type: 'toolResponse',
id: 'tool-1',
toolResult: {
status: 'error',
error: 'permission denied',
},
metadata: {
title: 'Read file',
status: 'failed',
rawOutput: 'permission denied',
},
});
});
it('uses failed tool response text content when raw output is absent', () => {
const adapter = createAcpSessionNotificationAdapter();
const failedToolStateChanges = adapter.apply(
acpUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: 'tool-1',
status: 'failed',
title: 'Read file',
content: [
{
type: 'content',
content: { type: 'text', text: 'file not found' },
},
],
})
);
const messages = expectOnlyMessagesChange(failedToolStateChanges);
expect(firstContent(messages[0])).toMatchObject({
type: 'toolResponse',
id: 'tool-1',
toolResult: {
status: 'error',
error: 'file not found',
},
});
});
});
});
describe('applyGoose', () => {
it('maps usage updates into token state', () => {
const adapter = createAcpSessionNotificationAdapter();
expect(
adapter.applyGoose(
gooseUpdate({
sessionUpdate: 'usage_update',
used: 42,
contextLimit: 200,
accumulatedInputTokens: 10,
accumulatedOutputTokens: 15,
accumulatedCost: 0.12,
})
)
).toEqual([
{
type: 'tokenState',
tokenState: {
totalTokens: 42,
accumulatedInputTokens: 10,
accumulatedOutputTokens: 15,
accumulatedTotalTokens: 25,
accumulatedCost: 0.12,
},
},
]);
});
it('maps status messages and keeps later id-less chunks separate', () => {
const adapter = createAcpSessionNotificationAdapter();
const noticeStateChanges = adapter.applyGoose(
gooseUpdate({
sessionUpdate: 'status_message',
status: { type: 'notice', message: 'Checking files' },
})
);
let messages = expectOnlyMessagesChange(noticeStateChanges);
expect(messages).toHaveLength(1);
expect(messages[0].metadata).toMatchObject({ userVisible: true, agentVisible: false });
expect(firstContent(messages[0])).toMatchObject({
type: 'systemNotification',
notificationType: 'inlineMessage',
msg: 'Checking files',
});
const textStateChanges = adapter.apply(agentText('Result'));
messages = expectOnlyMessagesChange(textStateChanges);
expect(messages).toHaveLength(2);
expect(firstContent(messages[1])).toMatchObject({ type: 'text', text: 'Result' });
const progressStateChanges = adapter.applyGoose(
gooseUpdate({
sessionUpdate: 'status_message',
status: { type: 'progress', message: 'Still working' },
})
);
messages = expectOnlyMessagesChange(progressStateChanges);
expect(firstContent(messages[2])).toMatchObject({
type: 'systemNotification',
notificationType: 'thinkingMessage',
msg: 'Still working',
});
});
});
describe('applyPermissionRequest', () => {
it('maps permission requests to action-required tool confirmations', () => {
const adapter = createAcpSessionNotificationAdapter();
const request: RequestPermissionRequest = {
sessionId: SESSION_ID,
options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }],
toolCall: {
toolCallId: 'tool-1',
title: 'Edit file',
rawInput: { path: 'README.md' },
content: [
{
type: 'content',
content: { type: 'text', text: 'Allow editing README.md?' },
},
],
_meta: {
goose: {
toolCall: {
toolName: 'edit_file',
},
},
},
},
};
const permissionStateChanges = adapter.applyPermissionRequest(request);
const messages = expectOnlyMessagesChange(permissionStateChanges);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe('assistant');
expect(firstContent(messages[0])).toMatchObject({
type: 'actionRequired',
data: {
actionType: 'toolConfirmation',
id: 'tool-1',
toolName: 'edit_file',
arguments: { path: 'README.md' },
prompt: 'Allow editing README.md?',
},
});
});
});
});
+8 -10
View File
@@ -1,25 +1,22 @@
import {
DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
GooseClient,
type Client,
type GooseClientCallbacks,
} from '@aaif/goose-sdk';
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
import packageJson from '../../package.json';
import { routeAcpGooseSessionNotification, routeAcpSessionNotification } from './chatNotifications';
import { createWebSocketStream } from './createWebSocketStream';
import { requestAcpPermission } from './permissionRequests';
let clientPromise: Promise<GooseClient> | null = null;
let resolvedClient: GooseClient | null = null;
function createClientCallbacks(): () => Client {
function createClientCallbacks(): () => GooseClientCallbacks {
return () => ({
requestPermission: async () => {
return {
outcome: {
outcome: 'cancelled',
},
};
},
sessionUpdate: async () => {},
requestPermission: requestAcpPermission,
sessionUpdate: routeAcpSessionNotification,
unstable_sessionUpdate: routeAcpGooseSessionNotification,
});
}
@@ -50,6 +47,7 @@ async function initializeConnection(): Promise<GooseClient> {
_meta: {
goose: {
mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
customNotifications: true,
},
},
},
@@ -0,0 +1,58 @@
import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk';
import { type AcpChatStateChange, type AdapterState, messagesChange } from './shared';
export function applyGooseSessionNotification(
state: AdapterState,
notification: GooseSessionNotification_unstable
): AcpChatStateChange[] {
const update = notification.update;
switch (update.sessionUpdate) {
case 'usage_update':
return [
{
type: 'tokenState',
tokenState: {
totalTokens: update.used,
accumulatedInputTokens: update.accumulatedInputTokens,
accumulatedOutputTokens: update.accumulatedOutputTokens,
accumulatedTotalTokens: update.accumulatedInputTokens + update.accumulatedOutputTokens,
...(update.accumulatedCost !== undefined
? { accumulatedCost: update.accumulatedCost }
: {}),
},
},
];
case 'status_message':
return applyStatusMessage(state, notification.sessionId, update);
default:
return [];
}
}
function applyStatusMessage(
state: AdapterState,
sessionId: string,
update: Extract<GooseSessionNotification_unstable['update'], { sessionUpdate: 'status_message' }>
): AcpChatStateChange[] {
const notificationType = update.status.type === 'notice' ? 'inlineMessage' : 'thinkingMessage';
state.messages.push({
id: `acp_status_${sessionId}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
role: 'assistant',
created: Math.floor(Date.now() / 1000),
content: [
{
type: 'systemNotification',
notificationType,
msg: update.status.message,
},
],
metadata: {
userVisible: true,
agentVisible: false,
},
});
return messagesChange(state);
}
+150
View File
@@ -0,0 +1,150 @@
import type {
ContentBlock as AcpContentBlock,
SessionNotification,
} from '@agentclientprotocol/sdk';
import type { Message, MessageContent } from '../../api';
import {
type AcpChatStateChange,
type AdapterState,
DEFAULT_VISIBLE_MESSAGE_METADATA,
getGooseMessageMeta,
messagesChange,
} from './shared';
export function applyContentChunk(
state: AdapterState,
role: Message['role'],
update: Extract<
SessionNotification['update'],
{ sessionUpdate: 'user_message_chunk' | 'agent_message_chunk' }
>
): AcpChatStateChange[] {
const content = messageContentFromAcpContentBlock(update.content);
if (!content) {
return [];
}
const gooseMeta = getGooseMessageMeta(update);
const messageId = update.messageId ?? gooseMeta.messageId;
const existing = findMessageForChunk(state, role, messageId, gooseMeta.created);
if (existing) {
const lastContent = existing.content[existing.content.length - 1];
if (lastContent?.type === 'text' && content.type === 'text') {
lastContent.text += content.text;
} else if (content.type === 'image' && hasImageContent(existing, content)) {
return messagesChange(state);
} else {
existing.content.push(content);
}
} else {
state.messages.push({
...(messageId ? { id: messageId } : {}),
role,
created: gooseMeta.created ?? Math.floor(Date.now() / 1000),
content: [content],
metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA },
});
}
return messagesChange(state);
}
export function applyThoughtChunk(
state: AdapterState,
update: Extract<SessionNotification['update'], { sessionUpdate: 'agent_thought_chunk' }>
): AcpChatStateChange[] {
if (update.content.type !== 'text') {
return [];
}
const gooseMeta = getGooseMessageMeta(update);
const messageId = update.messageId ?? gooseMeta.messageId;
const existing = findMessageForChunk(state, 'assistant', messageId, gooseMeta.created);
if (existing) {
const lastContent = existing.content[existing.content.length - 1];
if (lastContent?.type === 'thinking') {
lastContent.thinking += update.content.text;
} else {
existing.content.push({ type: 'thinking', thinking: update.content.text, signature: '' });
}
} else {
state.messages.push({
...(messageId ? { id: messageId } : {}),
role: 'assistant',
created: gooseMeta.created ?? Math.floor(Date.now() / 1000),
content: [{ type: 'thinking', thinking: update.content.text, signature: '' }],
metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA },
});
}
return messagesChange(state);
}
function messageContentFromAcpContentBlock(content: AcpContentBlock): MessageContent | undefined {
switch (content.type) {
case 'text':
return {
type: 'text',
text: content.text,
...(content._meta ? { _meta: content._meta } : {}),
...(content.annotations ? { annotations: content.annotations } : {}),
};
case 'image':
return {
type: 'image',
data: content.data,
mimeType: content.mimeType,
...(content._meta ? { _meta: content._meta } : {}),
...(content.annotations ? { annotations: content.annotations } : {}),
};
default:
return undefined;
}
}
export function findMessageForChunk(
state: AdapterState,
role: Message['role'],
messageId: string | undefined,
created: number | undefined
): Message | undefined {
if (!messageId) {
return lastMergeableMessageWithRole(state, role);
}
const existing = state.messages.find(
(message) => message.id === messageId && message.role === role
);
if (existing) {
return existing;
}
const pending = lastMergeableMessageWithRole(state, role);
if (pending && !pending.id) {
pending.id = messageId;
pending.created = created ?? pending.created;
return pending;
}
return undefined;
}
function lastMergeableMessageWithRole(
state: AdapterState,
role: Message['role']
): Message | undefined {
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage?.role !== role || lastMessage.metadata.agentVisible === false) {
return undefined;
}
return lastMessage;
}
function hasImageContent(message: Message, image: Extract<MessageContent, { type: 'image' }>) {
return message.content.some(
(content) =>
content.type === 'image' && content.data === image.data && content.mimeType === image.mimeType
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { RequestPermissionRequest } from '@agentclientprotocol/sdk';
import {
type AcpChatStateChange,
type AdapterState,
DEFAULT_VISIBLE_MESSAGE_METADATA,
messagesChange,
rawInputToArguments,
toolIdentity,
} from './shared';
export function applyPermissionRequest(
state: AdapterState,
request: RequestPermissionRequest
): AcpChatStateChange[] {
const toolCallId = request.toolCall.toolCallId;
const existing = state.messages.some((message) =>
message.content.some(
(content) =>
content.type === 'actionRequired' &&
content.data.actionType === 'toolConfirmation' &&
content.data.id === toolCallId
)
);
if (existing) {
return messagesChange(state);
}
const identity = toolIdentity(request.toolCall);
const prompt = permissionPrompt(request);
state.messages.push({
id: `acp_permission_${toolCallId}`,
role: 'assistant',
created: Math.floor(Date.now() / 1000),
content: [
{
type: 'actionRequired',
data: {
actionType: 'toolConfirmation',
id: toolCallId,
toolName: identity.toolName ?? request.toolCall.title ?? toolCallId,
arguments: rawInputToArguments(request.toolCall.rawInput),
...(prompt ? { prompt } : {}),
},
},
],
metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA },
});
return messagesChange(state);
}
function permissionPrompt(request: RequestPermissionRequest): string | undefined {
for (const content of request.toolCall.content ?? []) {
if (content.type === 'content' && content.content.type === 'text') {
return content.content.text;
}
}
return undefined;
}
+79
View File
@@ -0,0 +1,79 @@
import type { ToolCall, ToolCallUpdate } from '@agentclientprotocol/sdk';
import type { Message, TokenState } from '../../api';
export type AcpChatStateChange =
| { type: 'messages'; messages: Message[] }
| { type: 'tokenState'; tokenState: Partial<TokenState> }
| { type: 'sessionInfo'; name?: string };
export interface AdapterState {
messages: Message[];
}
export interface GooseMessageMeta {
messageId?: string;
created?: number;
}
export interface ToolIdentity {
toolName?: string;
extensionName?: string;
}
export const DEFAULT_VISIBLE_MESSAGE_METADATA: Message['metadata'] = {
userVisible: true,
agentVisible: true,
};
export function messagesChange(state: AdapterState): AcpChatStateChange[] {
return [{ type: 'messages', messages: state.messages.map(cloneMessage) }];
}
export function cloneMessage(message: Message): Message {
return {
...message,
content: message.content.map((content) => ({ ...content })),
metadata: { ...message.metadata },
};
}
export function getGooseMessageMeta(update: { _meta?: unknown }): GooseMessageMeta {
if (!isRecord(update._meta)) {
return {};
}
const goose = update._meta.goose;
if (!isRecord(goose)) {
return {};
}
return {
created: typeof goose.created === 'number' ? goose.created : undefined,
messageId: typeof goose.messageId === 'string' ? goose.messageId : undefined,
};
}
export function rawInputToArguments(rawInput: unknown): Record<string, unknown> {
return isRecord(rawInput) ? rawInput : {};
}
export function toolIdentity(update: ToolCall | ToolCallUpdate): ToolIdentity {
if (!isRecord(update._meta)) {
return {};
}
const goose = update._meta.goose;
if (!isRecord(goose) || !isRecord(goose.toolCall)) {
return {};
}
return {
toolName: typeof goose.toolCall.toolName === 'string' ? goose.toolCall.toolName : undefined,
extensionName:
typeof goose.toolCall.extensionName === 'string' ? goose.toolCall.extensionName : undefined,
};
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
+326
View File
@@ -0,0 +1,326 @@
import type {
ContentBlock as AcpContentBlock,
ToolCall,
ToolCallUpdate,
} from '@agentclientprotocol/sdk';
import type { CallToolResponse, ContentBlock as ApiContentBlock, Message } from '../../api';
import { findMessageForChunk } from './messages';
import {
type AcpChatStateChange,
type AdapterState,
DEFAULT_VISIBLE_MESSAGE_METADATA,
type GooseMessageMeta,
getGooseMessageMeta,
isRecord,
messagesChange,
rawInputToArguments,
toolIdentity,
type ToolIdentity,
} from './shared';
export function applyToolCall(state: AdapterState, update: ToolCall): AcpChatStateChange[] {
const gooseMeta = getGooseMessageMeta(update);
const message = getOrCreateAssistantMessageForUpdate(state, gooseMeta);
if (
message.content.some(
(content) => content.type === 'toolRequest' && content.id === update.toolCallId
)
) {
return messagesChange(state);
}
const identity = toolIdentity(update);
const metadata = toolRequestMetadata(update, identity);
message.content.push({
type: 'toolRequest',
id: update.toolCallId,
toolCall: {
status: 'success',
value: {
name: identity.toolName ?? update.title,
arguments: rawInputToArguments(update.rawInput),
},
},
...(metadata ? { metadata } : {}),
...(update._meta ? { _meta: update._meta } : {}),
});
return messagesChange(state);
}
export function applyToolCallUpdate(
state: AdapterState,
update: ToolCallUpdate
): AcpChatStateChange[] {
if (update.status !== 'completed' && update.status !== 'failed') {
return [];
}
if (hasToolResponse(state, update.toolCallId)) {
return messagesChange(state);
}
const gooseMeta = getGooseMessageMeta(update);
const message = getOrCreateToolResponseMessageForUpdate(state, gooseMeta);
const identity = toolIdentity(update);
const metadata = toolResponseMetadata(update, identity);
message.content.push({
type: 'toolResponse',
id: update.toolCallId,
toolResult:
update.status === 'failed'
? { status: 'error', error: toolError(update) }
: { status: 'success', value: toolResultValue(update, mcpAppMetadata(update)) },
...(metadata ? { metadata } : {}),
});
return messagesChange(state);
}
function getOrCreateAssistantMessageForUpdate(
state: AdapterState,
gooseMeta: GooseMessageMeta
): Message {
const existing = findMessageForChunk(state, 'assistant', gooseMeta.messageId, gooseMeta.created);
if (existing) {
return existing;
}
const message: Message = {
...(gooseMeta.messageId ? { id: gooseMeta.messageId } : {}),
role: 'assistant',
created: gooseMeta.created ?? Math.floor(Date.now() / 1000),
content: [],
metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA },
};
state.messages.push(message);
return message;
}
function getOrCreateToolResponseMessageForUpdate(
state: AdapterState,
gooseMeta: GooseMessageMeta
): Message {
if (gooseMeta.messageId) {
const existing = state.messages.find(
(message) => message.id === gooseMeta.messageId && message.role === 'user'
);
if (existing) {
return existing;
}
}
const message: Message = {
...(gooseMeta.messageId ? { id: gooseMeta.messageId } : {}),
role: 'user',
created: gooseMeta.created ?? Math.floor(Date.now() / 1000),
content: [],
metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA },
};
state.messages.push(message);
return message;
}
function hasToolResponse(state: AdapterState, toolCallId: string): boolean {
return state.messages.some((message) =>
message.content.some((content) => content.type === 'toolResponse' && content.id === toolCallId)
);
}
function toolRequestMetadata(
update: ToolCall,
identity: ToolIdentity
): Record<string, unknown> | undefined {
return baseToolMetadata(update, identity);
}
function toolResponseMetadata(
update: ToolCallUpdate,
identity: ToolIdentity
): Record<string, unknown> | undefined {
const metadata = baseToolMetadata(update, identity) ?? {};
if (update.rawOutput !== undefined) {
metadata.rawOutput = update.rawOutput;
}
if (update.content) {
metadata.content = update.content;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}
function baseToolMetadata(
update: ToolCall | ToolCallUpdate,
identity: ToolIdentity
): Record<string, unknown> | undefined {
const metadata: Record<string, unknown> = {};
if (update.title) {
metadata.title = update.title;
}
if (update.status) {
metadata.status = update.status;
}
if (identity.extensionName) {
metadata.extensionName = identity.extensionName;
}
if (update.kind) {
metadata.kind = update.kind;
}
if (update.locations) {
metadata.locations = update.locations;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}
function toolResultValue(
update: ToolCallUpdate,
mcpAppMeta: DesktopMcpAppMeta | undefined
): CallToolResponse {
return {
content: toolResultContent(update),
isError: false,
...(mcpAppMeta ? { _meta: mcpAppMeta } : {}),
};
}
function toolResultContent(update: ToolCallUpdate): ApiContentBlock[] {
const content: ApiContentBlock[] = [];
for (const item of update.content ?? []) {
if (item.type !== 'content') {
continue;
}
const block = apiContentBlockFromAcpContentBlock(item.content);
if (block) {
content.push(block);
}
}
if (content.length > 0) {
return content;
}
if (typeof update.rawOutput === 'string') {
return [{ type: 'text', text: update.rawOutput }];
}
return [];
}
function apiContentBlockFromAcpContentBlock(content: AcpContentBlock): ApiContentBlock | undefined {
switch (content.type) {
case 'text':
return {
type: 'text',
text: content.text,
...(content._meta ? { _meta: content._meta } : {}),
};
case 'image':
return {
type: 'image',
data: content.data,
mimeType: content.mimeType,
...(content._meta ? { _meta: content._meta } : {}),
};
case 'audio':
return {
type: 'audio',
data: content.data,
mimeType: content.mimeType,
};
case 'resource_link':
return {
type: 'resource_link',
uri: content.uri,
name: content.name,
...(content.description ? { description: content.description } : {}),
...(content.mimeType ? { mimeType: content.mimeType } : {}),
...(content.size !== undefined && content.size !== null ? { size: content.size } : {}),
...(content.title ? { title: content.title } : {}),
...(content._meta ? { _meta: content._meta } : {}),
};
case 'resource':
return {
type: 'resource',
resource: apiResourceContentsFromAcpResource(content.resource),
...(content._meta ? { _meta: content._meta } : {}),
};
default:
return undefined;
}
}
function apiResourceContentsFromAcpResource(
resource: Extract<AcpContentBlock, { type: 'resource' }>['resource']
): Extract<ApiContentBlock, { type: 'resource' }>['resource'] {
if ('text' in resource) {
return {
uri: resource.uri,
text: resource.text,
...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
...(resource._meta ? { _meta: resource._meta } : {}),
};
}
return {
uri: resource.uri,
blob: resource.blob,
...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
...(resource._meta ? { _meta: resource._meta } : {}),
};
}
function toolError(update: ToolCallUpdate): string {
if (typeof update.rawOutput === 'string' && update.rawOutput.trim()) {
return update.rawOutput;
}
const contentText = toolResultContent(update)
.flatMap((content) => (content.type === 'text' ? [content.text] : []))
.filter((text) => text.trim().length > 0)
.join('\n');
if (contentText) {
return contentText;
}
return update.title ?? 'Tool call failed';
}
interface DesktopMcpAppMeta extends Record<string, unknown> {
ui: {
resourceUri: string;
};
extensionName?: string;
toolName?: string;
}
function mcpAppMetadata(update: ToolCallUpdate): DesktopMcpAppMeta | undefined {
if (!isRecord(update._meta)) {
return undefined;
}
const goose = update._meta.goose;
if (!isRecord(goose) || !isRecord(goose.mcpApp)) {
return undefined;
}
const resourceUri = goose.mcpApp.resourceUri;
if (typeof resourceUri !== 'string') {
return undefined;
}
return {
ui: {
resourceUri,
},
extensionName:
typeof goose.mcpApp.extensionName === 'string' ? goose.mcpApp.extensionName : undefined,
toolName: typeof goose.mcpApp.toolName === 'string' ? goose.mcpApp.toolName : undefined,
};
}
+21
View File
@@ -0,0 +1,21 @@
import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk';
import type { SessionNotification } from '@agentclientprotocol/sdk';
import { createSessionScopedNotificationRouter } from './sessionScopedNotificationRouter';
const acpSessionRouter = createSessionScopedNotificationRouter<SessionNotification>();
const gooseSessionRouter =
createSessionScopedNotificationRouter<GooseSessionNotification_unstable>();
export const subscribeToAcpSession = acpSessionRouter.subscribe;
export const routeAcpSessionNotification = async (
notification: SessionNotification
): Promise<void> => {
await acpSessionRouter.route(notification);
};
export const subscribeToAcpGooseSession = gooseSessionRouter.subscribe;
export const routeAcpGooseSessionNotification = async (
notification: GooseSessionNotification_unstable
): Promise<void> => {
await gooseSessionRouter.route(notification);
};
+45
View File
@@ -0,0 +1,45 @@
export interface AcpCreditsExhaustedError {
message: string;
url?: string;
}
const CREDITS_EXHAUSTED_REASON = 'credits_exhausted';
export function parseAcpCreditsExhaustedError(error: unknown): AcpCreditsExhaustedError | null {
const jsonRpcError = asAcpJsonRpcError(error);
if (jsonRpcError?.data?.reason !== CREDITS_EXHAUSTED_REASON) {
return null;
}
const url = typeof jsonRpcError.data.url === 'string' ? jsonRpcError.data.url : undefined;
return {
message: jsonRpcError.message,
...(url ? { url } : {}),
};
}
interface AcpJsonRpcError {
message: string;
data: Record<string, unknown>;
}
function asAcpJsonRpcError(error: unknown): AcpJsonRpcError | null {
if (!isRecord(error)) {
return null;
}
const candidate = isRecord(error.error) ? error.error : error;
if (typeof candidate.message !== 'string' || !isRecord(candidate.data)) {
return null;
}
return {
message: candidate.message,
data: candidate.data,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
+132
View File
@@ -0,0 +1,132 @@
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
import type { Permission } from '../api';
import { createSessionScopedNotificationRouter } from './sessionScopedNotificationRouter';
interface PendingPermissionRequest {
request: RequestPermissionRequest;
resolve: (response: RequestPermissionResponse) => void;
}
const permissionRequestRouter = createSessionScopedNotificationRouter<RequestPermissionRequest>();
const pendingRequests = new Map<string, PendingPermissionRequest>();
export const subscribeToAcpPermissionRequests = permissionRequestRouter.subscribe;
export async function requestAcpPermission(
request: RequestPermissionRequest
): Promise<RequestPermissionResponse> {
const key = permissionRequestKey(request.sessionId, request.toolCall.toolCallId);
const previous = pendingRequests.get(key);
if (previous) {
previous.resolve(cancelledPermissionResponse());
}
return new Promise<RequestPermissionResponse>((resolve) => {
pendingRequests.set(key, { request, resolve });
permissionRequestRouter
.route(request)
.then((routed) => {
if (!routed) {
const pending = pendingRequests.get(key);
if (pending?.resolve === resolve) {
pendingRequests.delete(key);
resolve(cancelledPermissionResponse());
}
}
})
.catch((error) => {
console.warn('Failed to route ACP permission request:', error);
const pending = pendingRequests.get(key);
if (pending?.resolve === resolve) {
pendingRequests.delete(key);
resolve(cancelledPermissionResponse());
}
});
});
}
export function resolveAcpPermissionRequest(
sessionId: string,
toolCallId: string,
action: Permission
): boolean {
const key = permissionRequestKey(sessionId, toolCallId);
const pending = pendingRequests.get(key);
if (!pending) {
return false;
}
pendingRequests.delete(key);
pending.resolve(permissionResponseForAction(pending.request, action));
return true;
}
export function cancelAcpPermissionRequestsForSession(sessionId: string): void {
for (const [key, pending] of pendingRequests) {
if (pending.request.sessionId === sessionId) {
pendingRequests.delete(key);
pending.resolve(cancelledPermissionResponse());
}
}
}
function permissionResponseForAction(
request: RequestPermissionRequest,
action: Permission
): RequestPermissionResponse {
if (action === 'cancel') {
return cancelledPermissionResponse();
}
const optionId = permissionOptionIdForAction(request, action);
if (!optionId) {
return cancelledPermissionResponse();
}
return {
outcome: {
outcome: 'selected',
optionId,
},
};
}
function permissionOptionIdForAction(
request: RequestPermissionRequest,
action: Permission
): string | undefined {
const kind = permissionOptionKindForAction(action);
if (!kind) {
return undefined;
}
return request.options.find((candidate) => candidate.kind === kind)?.optionId;
}
function permissionOptionKindForAction(action: Permission) {
switch (action) {
case 'allow_once':
return 'allow_once';
case 'always_allow':
return 'allow_always';
case 'deny_once':
return 'reject_once';
case 'always_deny':
return 'reject_always';
case 'cancel':
return undefined;
}
}
function cancelledPermissionResponse(): RequestPermissionResponse {
return {
outcome: {
outcome: 'cancelled',
},
};
}
function permissionRequestKey(sessionId: string, toolCallId: string): string {
return `${sessionId}\u0000${toolCallId}`;
}
+45
View File
@@ -0,0 +1,45 @@
import type { ContentBlock, PromptResponse } from '@agentclientprotocol/sdk';
import type { Message } from '../api';
import { getAcpClient } from './acpConnection';
export async function acpPromptSession(
sessionId: string,
message: Message
): Promise<PromptResponse> {
const client = await getAcpClient();
return client.prompt({
sessionId,
prompt: messageToAcpPromptContent(message),
});
}
export async function acpCancelPrompt(sessionId: string): Promise<void> {
const client = await getAcpClient();
await client.cancel({ sessionId });
}
export function messageToAcpPromptContent(message: Message): ContentBlock[] {
const prompt: ContentBlock[] = [];
for (const content of message.content) {
switch (content.type) {
case 'text':
if (content.text.trim()) {
prompt.push({
type: 'text',
text: content.text,
});
}
break;
case 'image':
prompt.push({
type: 'image',
data: content.data,
mimeType: content.mimeType,
});
break;
}
}
return prompt;
}
@@ -0,0 +1,71 @@
import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk';
import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk';
import type { Message } from '../api';
import { applyGooseSessionNotification } from './adapter/gooseSessionNotifications';
import { applyContentChunk, applyThoughtChunk } from './adapter/messages';
import { applyPermissionRequest as applyPermissionRequestToState } from './adapter/permissions';
import { type AcpChatStateChange, type AdapterState, cloneMessage } from './adapter/shared';
import { applyToolCall, applyToolCallUpdate } from './adapter/tools';
export type { AcpChatStateChange } from './adapter/shared';
export interface AcpSessionNotificationAdapter {
apply(notification: SessionNotification): AcpChatStateChange[];
applyGoose(notification: GooseSessionNotification_unstable): AcpChatStateChange[];
applyPermissionRequest(request: RequestPermissionRequest): AcpChatStateChange[];
getMessages(): Message[];
}
export function createAcpSessionNotificationAdapter(
initialMessages: Message[] = []
): AcpSessionNotificationAdapter {
const state: AdapterState = {
messages: initialMessages.map(cloneMessage),
};
return {
apply(notification) {
return applyAcpSessionNotification(state, notification);
},
applyGoose(notification) {
return applyGooseSessionNotification(state, notification);
},
applyPermissionRequest(request) {
return applyPermissionRequestToState(state, request);
},
getMessages() {
return state.messages.map(cloneMessage);
},
};
}
function applyAcpSessionNotification(
state: AdapterState,
notification: SessionNotification
): AcpChatStateChange[] {
const update = notification.update;
switch (update.sessionUpdate) {
case 'user_message_chunk':
return applyContentChunk(state, 'user', update);
case 'agent_message_chunk':
return applyContentChunk(state, 'assistant', update);
case 'agent_thought_chunk':
return applyThoughtChunk(state, update);
case 'tool_call':
return applyToolCall(state, update);
case 'tool_call_update':
return applyToolCallUpdate(state, update);
case 'session_info_update':
return [
{
type: 'sessionInfo',
...(update.title ? { name: update.title } : {}),
},
];
case 'usage_update':
return [];
default:
return [];
}
}
@@ -0,0 +1,59 @@
type SessionScopedNotificationListener<TNotification> = (
notification: TNotification
) => Promise<void> | void;
interface SessionScopedNotification {
sessionId: string;
}
export function createSessionScopedNotificationRouter<
TNotification extends SessionScopedNotification,
>() {
const listenersBySessionId = new Map<
string,
Set<SessionScopedNotificationListener<TNotification>>
>();
const subscribe = (
sessionId: string,
listener: SessionScopedNotificationListener<TNotification>
): (() => void) => {
const listeners = listenersBySessionId.get(sessionId) ?? new Set();
listeners.add(listener);
listenersBySessionId.set(sessionId, listeners);
let subscribed = true;
return () => {
if (!subscribed) {
return;
}
subscribed = false;
const currentListeners = listenersBySessionId.get(sessionId);
if (!currentListeners) {
return;
}
currentListeners.delete(listener);
if (currentListeners.size === 0) {
listenersBySessionId.delete(sessionId);
}
};
};
const route = async (notification: TNotification): Promise<boolean> => {
const listeners = listenersBySessionId.get(notification.sessionId);
if (!listeners) {
return false;
}
await Promise.all([...listeners].map((listener) => listener(notification)));
return true;
};
return {
route,
subscribe,
};
}
+1
View File
@@ -0,0 +1 @@
export const USE_ACP_CHAT = false;
+2 -2
View File
@@ -16,7 +16,7 @@ import { ChatType } from '../types/chat';
import { useIsMobile } from '../hooks/use-mobile';
import { useNavigationContextSafe } from './Layout/NavigationContext';
import { cn } from '../utils';
import { useChatStream } from '../hooks/useChatStream';
import { useChatSession } from '../hooks/useChatSession';
import { useNavigation } from '../hooks/useNavigation';
import { RecipeHeader } from './RecipeHeader';
import { RecipeWarningModal } from './ui/RecipeWarningModal';
@@ -114,7 +114,7 @@ export default function BaseChat({
tokenState,
notifications: toolCallNotifications,
onMessageUpdate,
} = useChatStream({
} = useChatSession({
sessionId,
onStreamFinish,
});
@@ -0,0 +1,89 @@
import { render, type RenderOptions, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { confirmToolAction } from '../api';
import { resolveAcpPermissionRequest } from '../acp/permissionRequests';
import { IntlTestWrapper } from '../i18n/test-utils';
import ToolApprovalButtons from './ToolApprovalButtons';
vi.mock('../api', () => ({
confirmToolAction: vi.fn(),
}));
vi.mock('../acp/permissionRequests', () => ({
resolveAcpPermissionRequest: vi.fn(),
}));
vi.mock('../acpChatFeatureFlag', () => ({
USE_ACP_CHAT: true,
}));
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
render(ui, { wrapper: IntlTestWrapper, ...options });
const confirmToolActionMock = vi.mocked(confirmToolAction);
const resolveAcpPermissionRequestMock = vi.mocked(resolveAcpPermissionRequest);
describe('ToolApprovalButtons', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('marks the approval accepted when the ACP request resolves', async () => {
resolveAcpPermissionRequestMock.mockReturnValueOnce(true);
renderWithIntl(
<ToolApprovalButtons
data={{
id: 'tool-call-approved',
toolName: 'developer__shell',
sessionId: 'session-1',
}}
/>
);
await userEvent.click(screen.getByRole('button', { name: 'Allow Once' }));
expect(resolveAcpPermissionRequestMock).toHaveBeenCalledWith(
'session-1',
'tool-call-approved',
'allow_once'
);
expect(confirmToolActionMock).not.toHaveBeenCalled();
expect(screen.getByText('developer__shell - Allowed once')).toBeInTheDocument();
});
it('falls back to the REST confirmation when no ACP request is pending', async () => {
resolveAcpPermissionRequestMock.mockReturnValueOnce(false);
confirmToolActionMock.mockResolvedValueOnce({ error: undefined } as Awaited<
ReturnType<typeof confirmToolAction>
>);
renderWithIntl(
<ToolApprovalButtons
data={{
id: 'tool-call-rerun',
toolName: 'developer__shell',
sessionId: 'session-1',
}}
/>
);
await userEvent.click(screen.getByRole('button', { name: 'Allow Once' }));
expect(resolveAcpPermissionRequestMock).toHaveBeenCalledWith(
'session-1',
'tool-call-rerun',
'allow_once'
);
expect(confirmToolActionMock).toHaveBeenCalledWith({
body: {
sessionId: 'session-1',
id: 'tool-call-rerun',
action: 'allow_once',
principalType: 'Tool',
},
});
expect(screen.getByText('developer__shell - Allowed once')).toBeInTheDocument();
});
});
@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { confirmToolAction, Permission } from '../api';
import { resolveAcpPermissionRequest } from '../acp/permissionRequests';
import { USE_ACP_CHAT } from '../acpChatFeatureFlag';
import { defineMessages, useIntl } from '../i18n';
const i18n = defineMessages({
@@ -75,10 +77,18 @@ export default function ToolApprovalButtons({ data }: { data: ToolApprovalData }
}, [id, decision, isClicked]);
const handleAction = async (action: Permission) => {
setDecision(action);
setIsClicked(true);
try {
// Edit-in-place reruns go through the legacy REST path even when ACP chat is
// enabled, so fall back to confirmToolAction when no ACP request is pending.
if (USE_ACP_CHAT && resolveAcpPermissionRequest(sessionId, id, action)) {
setDecision(action);
setIsClicked(true);
return;
}
setDecision(action);
setIsClicked(true);
const response = await confirmToolAction({
body: {
sessionId,
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
import { USE_ACP_CHAT } from '../acpChatFeatureFlag';
import { useAcpChatSession } from './useAcpChatSession';
import { useChatStream } from './useChatStream';
import type { UseChatSessionHook } from './useChatSessionTypes';
export const useChatSession: UseChatSessionHook = USE_ACP_CHAT
? useAcpChatSession
: useChatStream;
@@ -0,0 +1,33 @@
import type { Message, Session, TokenState } from '../api';
import type { ChatState } from '../types/chatState';
import type { NotificationEvent, UserInput } from '../types/message';
export interface UseChatSessionParams {
sessionId: string;
onStreamFinish: () => void;
onSessionLoaded?: () => void;
}
export interface UseChatSessionResult {
session?: Session;
messages: Message[];
chatState: ChatState;
setChatState: (state: ChatState) => void;
handleSubmit: (input: UserInput) => Promise<void>;
submitElicitationResponse: (
elicitationId: string,
userData: Record<string, unknown>
) => Promise<void>;
setRecipeUserParams: (values: Record<string, string>) => Promise<void>;
stopStreaming: () => void;
sessionLoadError?: string;
tokenState: TokenState;
notifications: Map<string, NotificationEvent[]>;
onMessageUpdate: (
messageId: string,
newContent: string,
editType?: 'fork' | 'edit'
) => Promise<void>;
}
export type UseChatSessionHook = (params: UseChatSessionParams) => UseChatSessionResult;
+2 -29
View File
@@ -29,6 +29,7 @@ import { errorMessage } from '../utils/conversionUtils';
import { showExtensionLoadResults } from '../utils/extensionErrorUtils';
import { maybeHandlePlatformEvent } from '../utils/platform_events';
import { useSessionEvents, type SessionEvent } from './useSessionEvents';
import type { UseChatSessionParams, UseChatSessionResult } from './useChatSessionTypes';
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
@@ -36,34 +37,6 @@ export function clearSessionCache(sessionId: string): void {
resultsCache.delete(sessionId);
}
interface UseChatStreamProps {
sessionId: string;
onStreamFinish: () => void;
onSessionLoaded?: () => void;
}
interface UseChatStreamReturn {
session?: Session;
messages: Message[];
chatState: ChatState;
setChatState: (state: ChatState) => void;
handleSubmit: (input: UserInput) => Promise<void>;
submitElicitationResponse: (
elicitationId: string,
userData: Record<string, unknown>
) => Promise<void>;
setRecipeUserParams: (values: Record<string, string>) => Promise<void>;
stopStreaming: () => void;
sessionLoadError?: string;
tokenState: TokenState;
notifications: Map<string, NotificationEvent[]>;
onMessageUpdate: (
messageId: string,
newContent: string,
editType?: 'fork' | 'edit'
) => Promise<void>;
}
interface StreamState {
messages: Message[];
session: Session | undefined;
@@ -417,7 +390,7 @@ export function useChatStream({
sessionId,
onStreamFinish,
onSessionLoaded,
}: UseChatStreamProps): UseChatStreamReturn {
}: UseChatSessionParams): UseChatSessionResult {
const intl = useIntl();
const [state, dispatch] = useReducer(streamReducer, initialState);