feat (ui): chat reply UI with ACP (last part) (#9857)

This commit is contained in:
Lifei Zhou
2026-06-18 21:13:35 +10:00
committed by GitHub
parent ddf23a0b64
commit 7130cbf33e
14 changed files with 623 additions and 107 deletions
+11
View File
@@ -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',
},
},
});
});
});
});
+3 -1
View File
@@ -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,
},
};
}
+3 -1
View File
@@ -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)) {
+16
View File
@@ -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();
}
+8
View File
@@ -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">
+45 -87
View File
@@ -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,