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:
@@ -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?',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const USE_ACP_CHAT = false;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user