feat (ui): chat reply UI with ACP (last part) (#9857)
This commit is contained in:
@@ -94,6 +94,7 @@ mod onboarding;
|
||||
mod providers;
|
||||
mod resources;
|
||||
mod sources;
|
||||
mod tool_notifications;
|
||||
mod tools;
|
||||
|
||||
pub type AcpProviderFactory = Arc<
|
||||
@@ -2539,6 +2540,16 @@ impl GooseAcpAgent {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(crate::agents::AgentEvent::McpNotification((request_id, notification))) => {
|
||||
if let Some(update) =
|
||||
tool_notifications::tool_notification_update(request_id, notification)
|
||||
{
|
||||
cx.send_notification(SessionNotification::new(
|
||||
args.session_id.clone(),
|
||||
update,
|
||||
))?;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
stream_error = Some(
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
use agent_client_protocol::schema::{
|
||||
Meta, SessionUpdate, ToolCallId, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields,
|
||||
};
|
||||
use rmcp::model::{LoggingMessageNotificationParam, ProgressNotificationParam, ServerNotification};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ToolNotification {
|
||||
Message {
|
||||
params: LoggingMessageNotificationParam,
|
||||
},
|
||||
Progress {
|
||||
params: ProgressNotificationParam,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn tool_notification_update(
|
||||
tool_call_id: impl Into<ToolCallId>,
|
||||
notification: ServerNotification,
|
||||
) -> Option<SessionUpdate> {
|
||||
let tool_notification = match notification {
|
||||
ServerNotification::LoggingMessageNotification(notification) => ToolNotification::Message {
|
||||
params: notification.params,
|
||||
},
|
||||
ServerNotification::ProgressNotification(notification) => ToolNotification::Progress {
|
||||
params: notification.params,
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let mut meta = Meta::new();
|
||||
meta.insert(
|
||||
"toolNotification".to_string(),
|
||||
serde_json::to_value(tool_notification).ok()?,
|
||||
);
|
||||
|
||||
Some(SessionUpdate::ToolCallUpdate(
|
||||
ToolCallUpdate::new(
|
||||
tool_call_id,
|
||||
ToolCallUpdateFields::new().status(ToolCallStatus::InProgress),
|
||||
)
|
||||
.meta(meta),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tool_notification_update;
|
||||
use rmcp::model::{
|
||||
CancelledNotificationParam, LoggingLevel, LoggingMessageNotificationParam, Notification,
|
||||
NumberOrString, ProgressNotificationParam, ProgressToken, ServerNotification,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn maps_logging_message_notification_to_tool_update_meta() {
|
||||
let notification = ServerNotification::LoggingMessageNotification(Notification::new(
|
||||
LoggingMessageNotificationParam::new(
|
||||
LoggingLevel::Info,
|
||||
json!({
|
||||
"type": "subagent_tool_request",
|
||||
"subagent_id": "session_1",
|
||||
"tool_call": {
|
||||
"name": "developer__shell"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.with_logger("subagent:session_1"),
|
||||
));
|
||||
|
||||
let update = tool_notification_update("tool_1", notification).expect("expected update");
|
||||
let value = serde_json::to_value(update).expect("update should serialize");
|
||||
|
||||
assert_eq!(value["sessionUpdate"], "tool_call_update");
|
||||
assert_eq!(value["toolCallId"], "tool_1");
|
||||
assert_eq!(value["status"], "in_progress");
|
||||
assert_eq!(value["_meta"]["toolNotification"]["type"], "message");
|
||||
assert_eq!(
|
||||
value["_meta"]["toolNotification"]["params"]["level"],
|
||||
"info"
|
||||
);
|
||||
assert_eq!(
|
||||
value["_meta"]["toolNotification"]["params"]["logger"],
|
||||
"subagent:session_1"
|
||||
);
|
||||
assert_eq!(
|
||||
value["_meta"]["toolNotification"]["params"]["data"]["tool_call"]["name"],
|
||||
"developer__shell"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_progress_notification_to_tool_update_meta() {
|
||||
let notification = ServerNotification::ProgressNotification(Notification::new(
|
||||
ProgressNotificationParam::new(
|
||||
ProgressToken(NumberOrString::String(Arc::from("scan-repo"))),
|
||||
3.0,
|
||||
)
|
||||
.with_total(10.0)
|
||||
.with_message("Scanned 3 of 10 directories"),
|
||||
));
|
||||
|
||||
let update = tool_notification_update("tool_1", notification).expect("expected update");
|
||||
let value = serde_json::to_value(update).expect("update should serialize");
|
||||
|
||||
assert_eq!(value["sessionUpdate"], "tool_call_update");
|
||||
assert_eq!(value["toolCallId"], "tool_1");
|
||||
assert_eq!(value["status"], "in_progress");
|
||||
assert_eq!(value["_meta"]["toolNotification"]["type"], "progress");
|
||||
assert_eq!(
|
||||
value["_meta"]["toolNotification"]["params"]["progressToken"],
|
||||
"scan-repo"
|
||||
);
|
||||
assert_eq!(
|
||||
value["_meta"]["toolNotification"]["params"]["progress"],
|
||||
3.0
|
||||
);
|
||||
assert_eq!(value["_meta"]["toolNotification"]["params"]["total"], 10.0);
|
||||
assert_eq!(
|
||||
value["_meta"]["toolNotification"]["params"]["message"],
|
||||
"Scanned 3 of 10 directories"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_tool_live_notification_variants() {
|
||||
let notification = ServerNotification::CancelledNotification(Notification::new(
|
||||
CancelledNotificationParam {
|
||||
request_id: NumberOrString::String(Arc::from("request_1")),
|
||||
reason: None,
|
||||
},
|
||||
));
|
||||
|
||||
assert!(tool_notification_update("tool_1", notification).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Session } from '../../api';
|
||||
import { AppEvents } from '../../constants/events';
|
||||
import { ChatState } from '../../types/chatState';
|
||||
import { handleAcpSessionNotification } from '../chatNotifications';
|
||||
import type { AcpChatSessionSnapshot } from '../chatSessionStore';
|
||||
import { acpChatSessionStore } from '../chatSessionStore';
|
||||
|
||||
vi.mock('../../acpChatFeatureFlag', () => ({
|
||||
USE_ACP_CHAT: true,
|
||||
}));
|
||||
|
||||
vi.mock('../chatSessionStore', () => ({
|
||||
acpChatSessionStore: {
|
||||
getSnapshot: vi.fn(),
|
||||
applyAcpSessionNotification: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const SESSION_ID = 'session-1';
|
||||
|
||||
function sessionInfoUpdate(title: string): SessionNotification {
|
||||
return {
|
||||
sessionId: SESSION_ID,
|
||||
update: {
|
||||
sessionUpdate: 'session_info_update',
|
||||
title,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sessionWithName(name: string): Session {
|
||||
return {
|
||||
id: SESSION_ID,
|
||||
name,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
working_dir: '/tmp',
|
||||
message_count: 0,
|
||||
extension_data: {},
|
||||
source: 'test',
|
||||
} as Session;
|
||||
}
|
||||
|
||||
function snapshotWithName(name: string): AcpChatSessionSnapshot {
|
||||
return {
|
||||
session: sessionWithName(name),
|
||||
messages: [],
|
||||
tokenState: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
accumulatedInputTokens: 0,
|
||||
accumulatedOutputTokens: 0,
|
||||
accumulatedTotalTokens: 0,
|
||||
},
|
||||
notifications: [],
|
||||
chatState: ChatState.Idle,
|
||||
sessionLoadError: undefined,
|
||||
activePromptAttemptId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotWithoutSession(): AcpChatSessionSnapshot {
|
||||
return {
|
||||
session: undefined,
|
||||
messages: [],
|
||||
tokenState: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
accumulatedInputTokens: 0,
|
||||
accumulatedOutputTokens: 0,
|
||||
accumulatedTotalTokens: 0,
|
||||
},
|
||||
notifications: [],
|
||||
chatState: ChatState.Idle,
|
||||
sessionLoadError: undefined,
|
||||
activePromptAttemptId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('handleAcpSessionNotification', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('dispatches SESSION_RENAMED when a session info notification changes the name', async () => {
|
||||
const dispatchEvent = vi.spyOn(window, 'dispatchEvent');
|
||||
vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithName('Old name'));
|
||||
vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce(
|
||||
snapshotWithName('New name')
|
||||
);
|
||||
|
||||
await handleAcpSessionNotification(sessionInfoUpdate('New name'));
|
||||
|
||||
expect(dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: AppEvents.SESSION_RENAMED,
|
||||
detail: { sessionId: SESSION_ID, newName: 'New name' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not dispatch SESSION_RENAMED when the name is unchanged', async () => {
|
||||
const dispatchEvent = vi.spyOn(window, 'dispatchEvent');
|
||||
vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithName('Same name'));
|
||||
vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce(
|
||||
snapshotWithName('Same name')
|
||||
);
|
||||
|
||||
await handleAcpSessionNotification(sessionInfoUpdate('Same name'));
|
||||
|
||||
expect(dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches SESSION_RENAMED from the notification title when the session is not loaded', async () => {
|
||||
const dispatchEvent = vi.spyOn(window, 'dispatchEvent');
|
||||
vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithoutSession());
|
||||
vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce(
|
||||
snapshotWithoutSession()
|
||||
);
|
||||
|
||||
await handleAcpSessionNotification(sessionInfoUpdate('Generated name'));
|
||||
|
||||
expect(dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: AppEvents.SESSION_RENAMED,
|
||||
detail: { sessionId: SESSION_ID, newName: 'Generated name' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { CreateElicitationRequest, RequestPermissionRequest } from '@agentclientprotocol/sdk';
|
||||
import type {
|
||||
CreateElicitationRequest,
|
||||
RequestPermissionRequest,
|
||||
SessionNotification,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Message, Session } from '../../api';
|
||||
import { ChatState } from '../../types/chatState';
|
||||
@@ -86,6 +90,26 @@ function elicitationRequest(sessionId: string): {
|
||||
};
|
||||
}
|
||||
|
||||
function toolProgressNotification(sessionId: string): SessionNotification {
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'tool-1',
|
||||
status: 'in_progress',
|
||||
_meta: {
|
||||
toolNotification: {
|
||||
type: 'progress',
|
||||
params: {
|
||||
progressToken: 'scan-repo',
|
||||
progress: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('acpChatSessionStore', () => {
|
||||
let store: AcpChatSessionStore;
|
||||
|
||||
@@ -182,6 +206,27 @@ describe('acpChatSessionStore', () => {
|
||||
expect(snapshot.chatState).toBe(ChatState.Streaming);
|
||||
});
|
||||
|
||||
it('stores ACP tool notifications and clears them for a new prompt attempt', () => {
|
||||
const snapshot = store.applyAcpSessionNotification(toolProgressNotification('session-1'));
|
||||
|
||||
expect(snapshot.notifications).toHaveLength(1);
|
||||
expect(snapshot.notifications[0]).toMatchObject({
|
||||
type: 'Notification',
|
||||
request_id: 'tool-1',
|
||||
message: {
|
||||
method: 'notifications/progress',
|
||||
params: {
|
||||
progressToken: 'scan-repo',
|
||||
progress: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const nextSnapshot = store.startPromptAttempt('session-1', 'attempt-1');
|
||||
|
||||
expect(nextSnapshot.notifications).toEqual([]);
|
||||
});
|
||||
|
||||
it('applies permission requests as waiting action-required messages', () => {
|
||||
const snapshot = store.applyPermissionRequest(permissionRequest('session-1', 'tool-1'));
|
||||
|
||||
|
||||
@@ -147,6 +147,11 @@ describe('ACP elicitation requests', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(ACP_ELICITATION_TIMEOUT_SECONDS * 1000);
|
||||
|
||||
expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
appliedRequest.id,
|
||||
'cancelled'
|
||||
);
|
||||
await expect(response).resolves.toEqual({ action: 'cancel' });
|
||||
expect(resolveAcpElicitationRequest('session-1', appliedRequest.id, {})).toBe(false);
|
||||
} finally {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 type { NotificationEvent } from '../../types/message';
|
||||
import {
|
||||
createAcpSessionNotificationAdapter,
|
||||
type AcpChatStateChange,
|
||||
@@ -66,6 +67,21 @@ function expectOnlyMessagesChange(chatStateChanges: AcpChatStateChange[]): Messa
|
||||
return chatStateChange.messages;
|
||||
}
|
||||
|
||||
function expectOnlyNotificationChange(
|
||||
chatStateChanges: AcpChatStateChange[]
|
||||
): NotificationEvent {
|
||||
expect(chatStateChanges).toHaveLength(1);
|
||||
|
||||
const [chatStateChange] = chatStateChanges;
|
||||
expect(chatStateChange.type).toBe('notification');
|
||||
|
||||
if (chatStateChange.type !== 'notification') {
|
||||
throw new Error('expected notification state change');
|
||||
}
|
||||
|
||||
return chatStateChange.notification;
|
||||
}
|
||||
|
||||
function firstContent(message: Message): MessageContent {
|
||||
const content = message.content[0];
|
||||
expect(content).toBeDefined();
|
||||
@@ -286,6 +302,84 @@ describe('createAcpSessionNotificationAdapter', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps in-progress tool message notifications', () => {
|
||||
const adapter = createAcpSessionNotificationAdapter();
|
||||
|
||||
const notificationStateChanges = adapter.apply(
|
||||
acpUpdate({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'tool-1',
|
||||
status: 'in_progress',
|
||||
_meta: {
|
||||
toolNotification: {
|
||||
type: 'message',
|
||||
params: {
|
||||
level: 'info',
|
||||
logger: 'subagent:session-1',
|
||||
data: {
|
||||
text: 'Running search...',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const notification = expectOnlyNotificationChange(notificationStateChanges);
|
||||
|
||||
expect(notification).toMatchObject({
|
||||
type: 'Notification',
|
||||
request_id: 'tool-1',
|
||||
message: {
|
||||
method: 'notifications/message',
|
||||
params: {
|
||||
level: 'info',
|
||||
logger: 'subagent:session-1',
|
||||
data: {
|
||||
text: 'Running search...',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps in-progress tool progress notifications', () => {
|
||||
const adapter = createAcpSessionNotificationAdapter();
|
||||
|
||||
const notificationStateChanges = adapter.apply(
|
||||
acpUpdate({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'tool-1',
|
||||
status: 'in_progress',
|
||||
_meta: {
|
||||
toolNotification: {
|
||||
type: 'progress',
|
||||
params: {
|
||||
progressToken: 'scan-repo',
|
||||
progress: 3,
|
||||
total: 10,
|
||||
message: 'Scanned 3 of 10 directories',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const notification = expectOnlyNotificationChange(notificationStateChanges);
|
||||
|
||||
expect(notification).toMatchObject({
|
||||
type: 'Notification',
|
||||
request_id: 'tool-1',
|
||||
message: {
|
||||
method: 'notifications/progress',
|
||||
params: {
|
||||
progressToken: 'scan-repo',
|
||||
progress: 3,
|
||||
total: 10,
|
||||
message: 'Scanned 3 of 10 directories',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ToolCall, ToolCallUpdate } from '@agentclientprotocol/sdk';
|
||||
import type { Message, TokenState } from '../../api';
|
||||
import type { NotificationEvent } from '../../types/message';
|
||||
|
||||
export type AcpChatStateChange =
|
||||
| { type: 'messages'; messages: Message[] }
|
||||
| { type: 'tokenState'; tokenState: Partial<TokenState> }
|
||||
| { type: 'sessionInfo'; name?: string };
|
||||
| { type: 'sessionInfo'; name?: string }
|
||||
| { type: 'notification'; notification: NotificationEvent };
|
||||
|
||||
export interface AdapterState {
|
||||
messages: Message[];
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { ToolCallUpdate } from '@agentclientprotocol/sdk';
|
||||
import type { NotificationEvent } from '../../types/message';
|
||||
import type { AcpChatStateChange } from './shared';
|
||||
import { isRecord } from './shared';
|
||||
|
||||
type ToolNotification =
|
||||
| {
|
||||
type: 'message';
|
||||
params: LoggingMessageNotificationParams;
|
||||
}
|
||||
| {
|
||||
type: 'progress';
|
||||
params: ProgressNotificationParams;
|
||||
};
|
||||
|
||||
type LoggingMessageNotificationParams = {
|
||||
level: string;
|
||||
logger?: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
type ProgressNotificationParams = {
|
||||
progressToken: string | number;
|
||||
progress: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function toolNotificationChange(
|
||||
update: ToolCallUpdate
|
||||
): Extract<AcpChatStateChange, { type: 'notification' }> | undefined {
|
||||
const toolNotification = parseToolNotification(update._meta);
|
||||
if (!toolNotification) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'notification',
|
||||
notification: toNotificationEvent(update.toolCallId, toolNotification),
|
||||
};
|
||||
}
|
||||
|
||||
function parseToolNotification(meta: unknown): ToolNotification | undefined {
|
||||
if (!isRecord(meta)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const toolNotification = meta.toolNotification;
|
||||
if (!isRecord(toolNotification)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (toolNotification.type === 'message') {
|
||||
const params = parseLoggingMessageParams(toolNotification.params);
|
||||
return params ? { type: 'message', params } : undefined;
|
||||
}
|
||||
|
||||
if (toolNotification.type === 'progress') {
|
||||
const params = parseProgressParams(toolNotification.params);
|
||||
return params ? { type: 'progress', params } : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseLoggingMessageParams(value: unknown): LoggingMessageNotificationParams | undefined {
|
||||
if (!isRecord(value) || typeof value.level !== 'string' || !('data' in value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
level: value.level,
|
||||
...(typeof value.logger === 'string' ? { logger: value.logger } : {}),
|
||||
data: value.data,
|
||||
};
|
||||
}
|
||||
|
||||
function parseProgressParams(value: unknown): ProgressNotificationParams | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
(typeof value.progressToken !== 'string' && typeof value.progressToken !== 'number') ||
|
||||
typeof value.progress !== 'number'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
progressToken: value.progressToken,
|
||||
progress: value.progress,
|
||||
...(typeof value.total === 'number' ? { total: value.total } : {}),
|
||||
...(typeof value.message === 'string' ? { message: value.message } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toNotificationEvent(
|
||||
toolCallId: string,
|
||||
toolNotification: ToolNotification
|
||||
): NotificationEvent {
|
||||
return {
|
||||
type: 'Notification',
|
||||
request_id: toolCallId,
|
||||
message: {
|
||||
method:
|
||||
toolNotification.type === 'message'
|
||||
? 'notifications/message'
|
||||
: 'notifications/progress',
|
||||
params: toolNotification.params,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { CallToolResponse, ContentBlock as ApiContentBlock, Message } from '../../api';
|
||||
import { findMessageForChunk } from './messages';
|
||||
import { toolNotificationChange } from './toolNotifications';
|
||||
import {
|
||||
type AcpChatStateChange,
|
||||
type AdapterState,
|
||||
@@ -55,7 +56,8 @@ export function applyToolCallUpdate(
|
||||
update: ToolCallUpdate
|
||||
): AcpChatStateChange[] {
|
||||
if (update.status !== 'completed' && update.status !== 'failed') {
|
||||
return [];
|
||||
const notificationChange = toolNotificationChange(update);
|
||||
return notificationChange ? [notificationChange] : [];
|
||||
}
|
||||
|
||||
if (hasToolResponse(state, update.toolCallId)) {
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk';
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
||||
import { USE_ACP_CHAT } from '../acpChatFeatureFlag';
|
||||
import { AppEvents } from '../constants/events';
|
||||
import { acpChatSessionStore } from './chatSessionStore';
|
||||
|
||||
export function handleAcpSessionNotification(notification: SessionNotification): Promise<void> {
|
||||
if (USE_ACP_CHAT) {
|
||||
const sessionNameBeforeNotification = acpChatSessionStore.getSnapshot(
|
||||
notification.sessionId
|
||||
)?.session?.name;
|
||||
const updatedName =
|
||||
notification.update.sessionUpdate === 'session_info_update'
|
||||
? notification.update.title
|
||||
: undefined;
|
||||
acpChatSessionStore.applyAcpSessionNotification(notification);
|
||||
|
||||
if (updatedName && updatedName !== sessionNameBeforeNotification) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AppEvents.SESSION_RENAMED, {
|
||||
detail: { sessionId: notification.sessionId, newName: updatedName },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk';
|
||||
import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk';
|
||||
import type { Message, Session, TokenState } from '../api';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import type { NotificationEvent } from '../types/message';
|
||||
import {
|
||||
createAcpSessionNotificationAdapter,
|
||||
type AcpChatStateChange,
|
||||
@@ -15,6 +16,7 @@ export interface AcpChatSessionSnapshot {
|
||||
session: Session | undefined;
|
||||
messages: Message[];
|
||||
tokenState: TokenState;
|
||||
notifications: NotificationEvent[];
|
||||
chatState: ChatState;
|
||||
sessionLoadError: string | undefined;
|
||||
activePromptAttemptId: string | null;
|
||||
@@ -116,6 +118,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore {
|
||||
session: undefined,
|
||||
messages: [],
|
||||
tokenState: { ...initialTokenState },
|
||||
notifications: [],
|
||||
chatState: ChatState.Idle,
|
||||
sessionLoadError: undefined,
|
||||
activePromptAttemptId: null,
|
||||
@@ -193,6 +196,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore {
|
||||
entry.activePromptAttemptId = promptAttemptId;
|
||||
entry.chatState = ChatState.Streaming;
|
||||
entry.sessionLoadError = undefined;
|
||||
entry.notifications = [];
|
||||
return notify(sessionId, entry);
|
||||
};
|
||||
|
||||
@@ -333,6 +337,9 @@ function applyChatStateChanges(entry: StoreEntry, changes: AcpChatStateChange[])
|
||||
entry.session = { ...entry.session, name: change.name };
|
||||
}
|
||||
break;
|
||||
case 'notification':
|
||||
entry.notifications = [...entry.notifications, change.notification];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,6 +349,7 @@ function snapshotFromEntry(entry: StoreEntry): AcpChatSessionSnapshot {
|
||||
session: entry.session,
|
||||
messages: cloneMessages(entry.messages),
|
||||
tokenState: { ...entry.tokenState },
|
||||
notifications: [...entry.notifications],
|
||||
chatState: entry.chatState,
|
||||
sessionLoadError: entry.sessionLoadError,
|
||||
activePromptAttemptId: entry.activePromptAttemptId,
|
||||
|
||||
@@ -51,6 +51,11 @@ export async function requestAcpElicitation(
|
||||
}
|
||||
|
||||
pendingRequests.delete(key);
|
||||
acpChatSessionStore.setElicitationStatus(
|
||||
elicitationRequest.sessionId,
|
||||
elicitationRequest.id,
|
||||
'cancelled'
|
||||
);
|
||||
pending.resolve(cancelledElicitationResponse());
|
||||
}, ACP_ELICITATION_TIMEOUT_SECONDS * 1000);
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ import { AppEvents } from '../../constants/events';
|
||||
import { Goose } from '../icons/Goose';
|
||||
import { InlineEditText } from '../common/InlineEditText';
|
||||
import { SessionIndicators } from '../SessionIndicators';
|
||||
import { updateSessionName } from '../../api';
|
||||
import type { SessionListItem } from '../../acp/sessions';
|
||||
import { acpRenameSession, type SessionListItem } from '../../acp/sessions';
|
||||
import { cn } from '../../utils';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
|
||||
@@ -102,10 +101,7 @@ const SessionRow: React.FC<SessionRowProps> = ({ session, active, status, onClic
|
||||
<InlineEditText
|
||||
value={session.name}
|
||||
onSave={async (newName) => {
|
||||
await updateSessionName({
|
||||
path: { session_id: session.id },
|
||||
body: { name: newName },
|
||||
});
|
||||
await acpRenameSession(session.id, newName);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AppEvents.SESSION_RENAMED, {
|
||||
detail: { sessionId: session.id, newName, userInitiated: true },
|
||||
@@ -143,13 +139,8 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
|
||||
|
||||
const isActive = useCallback((path: string) => location.pathname === path, [location.pathname]);
|
||||
|
||||
const {
|
||||
recentSessions,
|
||||
activeSessionId,
|
||||
fetchSessions,
|
||||
handleNavClick,
|
||||
handleSessionClick,
|
||||
} = useNavigationSessions();
|
||||
const { recentSessions, activeSessionId, fetchSessions, handleNavClick, handleSessionClick } =
|
||||
useNavigationSessions();
|
||||
|
||||
const [sessionStatuses, setSessionStatuses] = useState<Map<string, SessionStatus>>(new Map());
|
||||
|
||||
@@ -205,10 +196,7 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={cn(
|
||||
'bg-background-primary outline-none flex flex-col h-full',
|
||||
className
|
||||
)}
|
||||
className={cn('bg-background-primary outline-none flex flex-col h-full', className)}
|
||||
>
|
||||
{/* Header: logo + collapse button. Top padding clears the macOS traffic lights. */}
|
||||
<div className="flex items-center justify-between px-4 pt-[34px] pb-2 no-drag">
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AppEvents } from '../constants/events';
|
||||
import { ChatState } from '../types/chatState';
|
||||
|
||||
import {
|
||||
getSession,
|
||||
Message,
|
||||
resumeAgent,
|
||||
Session,
|
||||
@@ -35,6 +34,7 @@ interface StreamState {
|
||||
chatState: ChatState;
|
||||
sessionLoadError: string | undefined;
|
||||
tokenState: TokenState;
|
||||
notifications: NotificationEvent[];
|
||||
}
|
||||
|
||||
type StreamAction =
|
||||
@@ -64,6 +64,7 @@ const initialState: StreamState = {
|
||||
chatState: ChatState.Idle,
|
||||
sessionLoadError: undefined,
|
||||
tokenState: initialTokenState,
|
||||
notifications: [],
|
||||
};
|
||||
|
||||
function streamReducer(state: StreamState, action: StreamAction): StreamState {
|
||||
@@ -89,6 +90,7 @@ function streamReducer(state: StreamState, action: StreamAction): StreamState {
|
||||
session: action.payload.session,
|
||||
messages: action.payload.messages,
|
||||
tokenState: action.payload.tokenState,
|
||||
notifications: action.payload.notifications,
|
||||
chatState: action.payload.chatState,
|
||||
sessionLoadError: action.payload.sessionLoadError,
|
||||
};
|
||||
@@ -99,6 +101,7 @@ function streamReducer(state: StreamState, action: StreamAction): StreamState {
|
||||
messages: [],
|
||||
session: undefined,
|
||||
sessionLoadError: undefined,
|
||||
notifications: [],
|
||||
chatState: ChatState.LoadingConversation,
|
||||
};
|
||||
|
||||
@@ -106,6 +109,7 @@ function streamReducer(state: StreamState, action: StreamAction): StreamState {
|
||||
return {
|
||||
...state,
|
||||
chatState: ChatState.Streaming,
|
||||
notifications: [],
|
||||
};
|
||||
|
||||
case 'STREAM_ERROR':
|
||||
@@ -127,6 +131,10 @@ function streamReducer(state: StreamState, action: StreamAction): StreamState {
|
||||
}
|
||||
}
|
||||
|
||||
function isClearCommand(message: string): boolean {
|
||||
return message.trim() === '/clear';
|
||||
}
|
||||
|
||||
function createAcpCreditsExhaustedMessage(error: AcpCreditsExhaustedError): Message {
|
||||
return {
|
||||
id: uuidv7(),
|
||||
@@ -163,8 +171,6 @@ export function useAcpChatSession({
|
||||
const intl = useIntl();
|
||||
const [state, dispatch] = useReducer(streamReducer, initialState);
|
||||
|
||||
const namePollingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Ref to access latest state in callbacks (avoids stale closures)
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
@@ -185,21 +191,31 @@ export function useAcpChatSession({
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (namePollingRef.current) {
|
||||
clearTimeout(namePollingRef.current);
|
||||
namePollingRef.current = null;
|
||||
const handleSessionRenamed = (event: Event) => {
|
||||
const { sessionId: renamedSessionId, newName } = (
|
||||
event as CustomEvent<{ sessionId: string; newName: string }>
|
||||
).detail;
|
||||
|
||||
if (renamedSessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSession = stateRef.current.session;
|
||||
if (!currentSession || currentSession.name === newName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSession = { ...currentSession, name: newName };
|
||||
acpChatSessionStore.setSessionMetadata(sessionId, updatedSession);
|
||||
dispatch({ type: 'SET_SESSION', payload: updatedSession });
|
||||
};
|
||||
|
||||
window.addEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed);
|
||||
return () => window.removeEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed);
|
||||
}, [sessionId]);
|
||||
|
||||
const onFinish = useCallback(
|
||||
async (error?: string): Promise<void> => {
|
||||
if (namePollingRef.current) {
|
||||
clearTimeout(namePollingRef.current);
|
||||
namePollingRef.current = null;
|
||||
}
|
||||
|
||||
acpChatSessionStore.setSessionLoadError(sessionId, error);
|
||||
acpChatSessionStore.setChatState(sessionId, ChatState.Idle);
|
||||
dispatch({ type: 'STREAM_FINISH', payload: error });
|
||||
@@ -226,35 +242,6 @@ export function useAcpChatSession({
|
||||
window.dispatchEvent(new CustomEvent(AppEvents.MESSAGE_STREAM_FINISHED));
|
||||
}
|
||||
|
||||
// Refresh session name after each reply for the first 3 user messages
|
||||
if (!error && sessionId) {
|
||||
const currentState = stateRef.current;
|
||||
const userMessageCount = currentState.messages.filter((m) => m.role === 'user').length;
|
||||
|
||||
if (userMessageCount <= 3) {
|
||||
try {
|
||||
const response = await getSession({
|
||||
path: { session_id: sessionId },
|
||||
throwOnError: true,
|
||||
});
|
||||
if (response.data?.name) {
|
||||
const updatedSession = currentState.session
|
||||
? { ...currentState.session, name: response.data.name }
|
||||
: undefined;
|
||||
acpChatSessionStore.setSessionMetadata(sessionId, updatedSession);
|
||||
dispatch({ type: 'SET_SESSION', payload: updatedSession });
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AppEvents.SESSION_RENAMED, {
|
||||
detail: { sessionId, newName: response.data.name },
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.warn('Failed to refresh session name:', refreshError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onStreamFinish();
|
||||
},
|
||||
[intl, onStreamFinish, sessionId]
|
||||
@@ -393,6 +380,7 @@ export function useAcpChatSession({
|
||||
|
||||
const hasExistingMessages = currentState.messages.length > 0;
|
||||
const hasNewMessage = userMessage.trim().length > 0 || images.length > 0;
|
||||
const clearsConversation = hasNewMessage && isClearCommand(userMessage);
|
||||
|
||||
if (!hasNewMessage && !hasExistingMessages) {
|
||||
return;
|
||||
@@ -401,57 +389,18 @@ export function useAcpChatSession({
|
||||
// Emit session-created event for first message in a new session
|
||||
if (!hasExistingMessages && hasNewMessage) {
|
||||
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
|
||||
|
||||
const pollForName = async (attempts = 0) => {
|
||||
if (attempts >= 20) return;
|
||||
|
||||
try {
|
||||
const response = await getSession({
|
||||
path: { session_id: sessionId },
|
||||
throwOnError: true,
|
||||
});
|
||||
const currentState = stateRef.current;
|
||||
const currentName = currentState.session?.name;
|
||||
const newName = response.data?.name;
|
||||
|
||||
if (newName && newName !== currentName) {
|
||||
const updatedSession = currentState.session
|
||||
? { ...currentState.session, name: newName }
|
||||
: undefined;
|
||||
acpChatSessionStore.setSessionMetadata(sessionId, updatedSession);
|
||||
dispatch({ type: 'SET_SESSION', payload: updatedSession });
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AppEvents.SESSION_RENAMED, {
|
||||
detail: { sessionId, newName },
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Silently continue polling
|
||||
}
|
||||
|
||||
const latestState = stateRef.current;
|
||||
if (
|
||||
latestState.chatState === ChatState.Streaming ||
|
||||
latestState.chatState === ChatState.Thinking ||
|
||||
latestState.chatState === ChatState.Compacting
|
||||
) {
|
||||
namePollingRef.current = setTimeout(() => pollForName(attempts + 1), 500);
|
||||
}
|
||||
};
|
||||
|
||||
namePollingRef.current = setTimeout(() => pollForName(0), 1000);
|
||||
}
|
||||
|
||||
const newMessage = hasNewMessage
|
||||
? createUserMessage(userMessage, images)
|
||||
: currentState.messages[currentState.messages.length - 1];
|
||||
const currentMessages = hasNewMessage
|
||||
? [...currentState.messages, newMessage]
|
||||
: [...currentState.messages];
|
||||
const currentMessages = clearsConversation
|
||||
? []
|
||||
: hasNewMessage
|
||||
? [...currentState.messages, newMessage]
|
||||
: [...currentState.messages];
|
||||
|
||||
if (hasNewMessage) {
|
||||
if (clearsConversation || hasNewMessage) {
|
||||
acpChatSessionStore.setMessages(sessionId, currentMessages);
|
||||
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
|
||||
}
|
||||
@@ -622,7 +571,16 @@ export function useAcpChatSession({
|
||||
const maybe_cached_messages = state.session ? state.messages : cached?.messages || [];
|
||||
const maybe_cached_session = state.session ?? cached?.session;
|
||||
|
||||
const notificationsMap = useMemo(() => new Map<string, NotificationEvent[]>(), []);
|
||||
const notificationsMap = useMemo(() => {
|
||||
return state.notifications.reduce((map, notification) => {
|
||||
const key = notification.request_id;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key)!.push(notification);
|
||||
return map;
|
||||
}, new Map<string, NotificationEvent[]>());
|
||||
}, [state.notifications]);
|
||||
|
||||
return {
|
||||
sessionLoadError: state.sessionLoadError,
|
||||
|
||||
Reference in New Issue
Block a user