feat (ui): Add durable ACP chat session state for desktop UI (#9852)

This commit is contained in:
Lifei Zhou
2026-06-18 18:06:14 +10:00
committed by GitHub
parent 802dd39165
commit ddf23a0b64
38 changed files with 1567 additions and 885 deletions
@@ -491,6 +491,18 @@ pub struct GetSessionInfoResponse {
pub session: SessionInfo,
}
/// Truncate a session conversation from the given message timestamp onward.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(
method = "_goose/unstable/session/conversation/truncate",
response = EmptyResponse
)]
#[serde(rename_all = "camelCase")]
pub struct TruncateSessionConversationRequest {
pub session_id: String,
pub truncate_from: i64,
}
/// Update the project association for a session.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/unstable/session/project/update", response = EmptyResponse)]
+5
View File
@@ -200,6 +200,11 @@
"requestType": "GetSessionInfoRequest_unstable",
"responseType": "GetSessionInfoResponse_unstable"
},
{
"method": "_goose/unstable/session/conversation/truncate",
"requestType": "TruncateSessionConversationRequest_unstable",
"responseType": "EmptyResponse"
},
{
"method": "_goose/unstable/session/project/update",
"requestType": "UpdateSessionProjectRequest_unstable",
+27
View File
@@ -2768,6 +2768,24 @@
"type": "string",
"description": "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)"
},
"TruncateSessionConversationRequest_unstable": {
"type": "object",
"properties": {
"sessionId": {
"type": "string"
},
"truncateFrom": {
"type": "integer"
}
},
"required": [
"sessionId",
"truncateFrom"
],
"description": "Truncate a session conversation from the given message timestamp onward.",
"x-side": "agent",
"x-method": "_goose/unstable/session/conversation/truncate"
},
"UpdateSessionProjectRequest_unstable": {
"type": "object",
"properties": {
@@ -4043,6 +4061,15 @@
"description": "Params for _goose/unstable/session/info",
"title": "GetSessionInfoRequest_unstable"
},
{
"allOf": [
{
"$ref": "#/$defs/TruncateSessionConversationRequest_unstable"
}
],
"description": "Params for _goose/unstable/session/conversation/truncate",
"title": "TruncateSessionConversationRequest_unstable"
},
{
"allOf": [
{
@@ -329,6 +329,14 @@ impl GooseAcpAgent {
self.on_get_session_info(req).await
}
#[custom_method(TruncateSessionConversationRequest)]
async fn dispatch_truncate_session_conversation(
&self,
req: TruncateSessionConversationRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
self.on_truncate_session_conversation(req).await
}
#[custom_method(UpdateSessionProjectRequest)]
async fn dispatch_update_session_project(
&self,
@@ -142,6 +142,24 @@ impl GooseAcpAgent {
})
}
pub(super) async fn on_truncate_session_conversation(
&self,
req: TruncateSessionConversationRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
let session_id = req.session_id.trim();
if session_id.is_empty() {
return Err(
agent_client_protocol::Error::invalid_params().data("sessionId cannot be empty")
);
}
self.session_manager
.truncate_conversation(session_id, req.truncate_from)
.await
.internal_err()?;
Ok(EmptyResponse {})
}
pub(super) async fn on_update_session_project(
&self,
req: UpdateSessionProjectRequest,
@@ -0,0 +1,247 @@
import type { CreateElicitationRequest, RequestPermissionRequest } from '@agentclientprotocol/sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Message, Session } from '../../api';
import { ChatState } from '../../types/chatState';
import { createAcpChatSessionStore, type AcpChatSessionStore } from '../chatSessionStore';
function message(id: string, text: string): Message {
return {
id,
role: 'user',
created: 123,
content: [{ type: 'text', text }],
metadata: { userVisible: true, agentVisible: true },
};
}
function session(id: string, conversation: Message[] = []): Session {
return {
id,
name: `Session ${id}`,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
working_dir: '/tmp',
message_count: conversation.length,
extension_data: {},
source: 'test',
conversation,
input_tokens: 1,
output_tokens: 2,
total_tokens: 3,
accumulated_input_tokens: 4,
accumulated_output_tokens: 5,
accumulated_total_tokens: 9,
} as Session;
}
function permissionRequest(sessionId: string, toolCallId = 'tool-1'): RequestPermissionRequest {
return {
sessionId,
options: [{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }],
toolCall: {
toolCallId,
title: 'Edit file',
rawInput: { path: 'README.md' },
content: [
{
type: 'content',
content: { type: 'text', text: 'Allow editing README.md?' },
},
],
_meta: {
goose: {
toolCall: {
toolName: 'edit_file',
},
},
},
},
};
}
function elicitationRequest(sessionId: string): {
id: string;
sessionId: string;
request: CreateElicitationRequest & {
mode: 'form';
sessionId: string;
};
} {
return {
id: 'acp_elicitation_1',
sessionId,
request: {
mode: 'form',
sessionId,
message: 'Choose a project',
requestedSchema: {
type: 'object',
properties: {
project: {
type: 'string',
},
},
},
},
};
}
describe('acpChatSessionStore', () => {
let store: AcpChatSessionStore;
beforeEach(() => {
store = createAcpChatSessionStore();
});
it('stores loaded session messages and token state', () => {
const initialMessage = message('message-1', 'Hello');
const snapshot = store.setLoadedSession('session-1', session('session-1', [initialMessage]));
expect(snapshot.session?.id).toBe('session-1');
expect(snapshot.messages).toEqual([initialMessage]);
expect(snapshot.tokenState).toMatchObject({
inputTokens: 1,
outputTokens: 2,
totalTokens: 3,
accumulatedInputTokens: 4,
accumulatedOutputTokens: 5,
accumulatedTotalTokens: 9,
});
expect(snapshot.chatState).toBe(ChatState.Idle);
expect(snapshot.sessionLoadError).toBeUndefined();
});
it('keeps multiple session snapshots isolated', () => {
store.setMessages('session-1', [message('message-1', 'One')]);
store.setMessages('session-2', [message('message-2', 'Two')]);
expect(store.getSnapshot('session-1')?.messages[0].id).toBe('message-1');
expect(store.getSnapshot('session-2')?.messages[0].id).toBe('message-2');
});
it('deletes session snapshots', () => {
store.setMessages('session-1', [message('message-1', 'One')]);
store.deleteSnapshot('session-1');
expect(store.getSnapshot('session-1')).toBeUndefined();
});
it('notifies only listeners for the updated session', () => {
const sessionOneListener = vi.fn();
const sessionTwoListener = vi.fn();
store.subscribe('session-1', sessionOneListener);
store.subscribe('session-2', sessionTwoListener);
store.setChatState('session-1', ChatState.Streaming);
expect(sessionOneListener).toHaveBeenCalledTimes(1);
expect(sessionOneListener).toHaveBeenCalledWith(
expect.objectContaining({ chatState: ChatState.Streaming })
);
expect(sessionTwoListener).not.toHaveBeenCalled();
});
it('stops notifying after unsubscribe', () => {
const listener = vi.fn();
const unsubscribe = store.subscribe('session-1', listener);
unsubscribe();
store.setChatState('session-1', ChatState.Streaming);
expect(listener).not.toHaveBeenCalled();
});
it('ignores stale prompt attempts and leaves the current attempt active', () => {
store.startPromptAttempt('session-1', 'attempt-a');
store.startPromptAttempt('session-1', 'attempt-b');
expect(store.finishPromptAttemptIfCurrent('session-1', 'attempt-a', 'late error')).toBe(false);
expect(store.getSnapshot('session-1')).toMatchObject({
activePromptAttemptId: 'attempt-b',
chatState: ChatState.Streaming,
sessionLoadError: undefined,
});
expect(store.finishPromptAttemptIfCurrent('session-1', 'attempt-b')).toBe(true);
expect(store.getSnapshot('session-1')).toMatchObject({
activePromptAttemptId: null,
chatState: ChatState.Idle,
});
});
it('keeps loaded sessions streaming when a prompt attempt is active', () => {
store.startPromptAttempt('session-1', 'attempt-1');
const snapshot = store.setLoadedSession('session-1', session('session-1'));
expect(snapshot.activePromptAttemptId).toBe('attempt-1');
expect(snapshot.chatState).toBe(ChatState.Streaming);
});
it('applies permission requests as waiting action-required messages', () => {
const snapshot = store.applyPermissionRequest(permissionRequest('session-1', 'tool-1'));
expect(snapshot.chatState).toBe(ChatState.WaitingForUserInput);
expect(snapshot.messages).toHaveLength(1);
expect(snapshot.messages[0].role).toBe('assistant');
expect(snapshot.messages[0].content[0]).toMatchObject({
type: 'actionRequired',
data: {
actionType: 'toolConfirmation',
id: 'tool-1',
},
});
});
it('applies elicitation requests as waiting action-required messages', () => {
const snapshot = store.applyElicitationRequest(elicitationRequest('session-1'));
expect(snapshot.chatState).toBe(ChatState.WaitingForUserInput);
expect(snapshot.messages).toHaveLength(1);
expect(snapshot.messages[0].role).toBe('assistant');
expect(snapshot.messages[0].content[0]).toMatchObject({
type: 'actionRequired',
data: {
actionType: 'elicitation',
id: 'acp_elicitation_1',
message: 'Choose a project',
},
});
});
it('stores submitted elicitation status', () => {
store.applyElicitationRequest(elicitationRequest('session-1'));
const snapshot = store.setElicitationStatus('session-1', 'acp_elicitation_1', 'submitted');
expect(snapshot?.messages[0].content[0]).toMatchObject({
type: 'actionRequired',
data: {
actionType: 'elicitation',
id: 'acp_elicitation_1',
isSubmitted: true,
isCancelled: false,
},
});
});
it('stores cancelled elicitation status', () => {
store.applyElicitationRequest(elicitationRequest('session-1'));
const snapshot = store.setElicitationStatus('session-1', 'acp_elicitation_1', 'cancelled');
expect(snapshot?.messages[0].content[0]).toMatchObject({
type: 'actionRequired',
data: {
actionType: 'elicitation',
id: 'acp_elicitation_1',
isSubmitted: false,
isCancelled: true,
},
});
});
});
@@ -0,0 +1,156 @@
import type { CreateElicitationRequest, CreateElicitationResponse } from '@agentclientprotocol/sdk';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ACP_ELICITATION_TIMEOUT_SECONDS,
cancelAcpElicitationRequestsForSession,
requestAcpElicitation,
resolveAcpElicitationRequest,
} from '../elicitationRequests';
import { acpChatSessionStore } from '../chatSessionStore';
vi.mock('../../acpChatFeatureFlag', () => ({
USE_ACP_CHAT: true,
}));
vi.mock('../chatSessionStore', () => ({
acpChatSessionStore: {
applyElicitationRequest: vi.fn(),
setElicitationStatus: vi.fn(),
},
}));
const TEST_SESSION_IDS = ['session-1', 'session-2'];
function formRequest(sessionId: string): CreateElicitationRequest {
return {
mode: 'form',
sessionId,
message: 'Choose a project',
requestedSchema: {
type: 'object',
properties: {
project: {
type: 'string',
},
},
required: ['project'],
},
};
}
async function expectStillPending(promise: Promise<CreateElicitationResponse>): Promise<void> {
let settled = false;
promise.then(
() => {
settled = true;
},
() => {
settled = true;
}
);
await Promise.resolve();
expect(settled).toBe(false);
}
describe('ACP elicitation requests', () => {
beforeEach(() => {
vi.clearAllMocks();
for (const sessionId of TEST_SESSION_IDS) {
cancelAcpElicitationRequestsForSession(sessionId);
}
});
afterEach(() => {
for (const sessionId of TEST_SESSION_IDS) {
cancelAcpElicitationRequestsForSession(sessionId);
}
});
it('keeps form requests pending until explicit resolve', async () => {
const response = requestAcpElicitation(formRequest('session-1'));
await expectStillPending(response);
const appliedRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest).mock.calls[0][0];
expect(appliedRequest.id).toMatch(/^acp_elicitation_/);
expect(appliedRequest.sessionId).toBe('session-1');
expect(appliedRequest.request.message).toBe('Choose a project');
expect(
resolveAcpElicitationRequest('session-1', appliedRequest.id, {
project: 'goose',
})
).toBe(true);
expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith(
'session-1',
appliedRequest.id,
'submitted'
);
await expect(response).resolves.toEqual({
action: 'accept',
content: {
project: 'goose',
},
});
});
it('cancels unsupported requests', async () => {
await expect(
requestAcpElicitation({
mode: 'url',
requestId: 'request-1',
elicitationId: 'elicitation-1',
message: 'Open this page',
url: 'https://example.com',
})
).resolves.toEqual({ action: 'cancel' });
});
it('cancels only pending requests for the requested session', async () => {
const sessionOneResponse = requestAcpElicitation(formRequest('session-1'));
const sessionTwoResponse = requestAcpElicitation(formRequest('session-2'));
const applyElicitationRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest);
const sessionOneRequest = applyElicitationRequest.mock.calls[0][0];
const sessionTwoRequest = applyElicitationRequest.mock.calls[1][0];
cancelAcpElicitationRequestsForSession('session-1');
expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith(
'session-1',
sessionOneRequest.id,
'cancelled'
);
await expect(sessionOneResponse).resolves.toEqual({ action: 'cancel' });
await expectStillPending(sessionTwoResponse);
expect(resolveAcpElicitationRequest('session-2', sessionTwoRequest.id, {})).toBe(true);
await expect(sessionTwoResponse).resolves.toEqual({
action: 'accept',
content: {},
});
expect(resolveAcpElicitationRequest('session-1', sessionOneRequest.id, {})).toBe(false);
});
it('cancels pending requests when they expire', async () => {
vi.useFakeTimers();
try {
const response = requestAcpElicitation(formRequest('session-1'));
const appliedRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest).mock
.calls[0][0];
await expectStillPending(response);
await vi.advanceTimersByTimeAsync(ACP_ELICITATION_TIMEOUT_SECONDS * 1000);
await expect(response).resolves.toEqual({ action: 'cancel' });
expect(resolveAcpElicitationRequest('session-1', appliedRequest.id, {})).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,126 @@
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
cancelAcpPermissionRequestsForSession,
requestAcpPermission,
resolveAcpPermissionRequest,
} from '../permissionRequests';
vi.mock('../../acpChatFeatureFlag', () => ({
USE_ACP_CHAT: true,
}));
vi.mock('../chatSessionStore', () => ({
acpChatSessionStore: {
applyPermissionRequest: vi.fn(),
},
}));
function permissionRequest(sessionId: string, toolCallId: string): RequestPermissionRequest {
return {
sessionId,
options: [
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject-once', name: 'Deny once', kind: 'reject_once' },
],
toolCall: {
toolCallId,
title: 'Read file',
rawInput: { path: 'README.md' },
content: [
{
type: 'content',
content: { type: 'text', text: 'Allow reading README.md?' },
},
],
},
};
}
const TEST_SESSION_IDS = ['session-1', 'session-2'];
async function expectStillPending(promise: Promise<RequestPermissionResponse>): Promise<void> {
let settled = false;
promise.then(
() => {
settled = true;
},
() => {
settled = true;
}
);
await Promise.resolve();
expect(settled).toBe(false);
}
describe('ACP permission requests', () => {
beforeEach(() => {
for (const sessionId of TEST_SESSION_IDS) {
cancelAcpPermissionRequestsForSession(sessionId);
}
});
afterEach(() => {
for (const sessionId of TEST_SESSION_IDS) {
cancelAcpPermissionRequestsForSession(sessionId);
}
});
it('keeps permission requests pending until explicit resolve', async () => {
const response = requestAcpPermission(permissionRequest('session-1', 'tool-1'));
await expectStillPending(response);
expect(resolveAcpPermissionRequest('session-1', 'tool-1', 'allow_once')).toBe(true);
await expect(response).resolves.toEqual({
outcome: {
outcome: 'selected',
optionId: 'allow-once',
},
});
});
it('cancels only pending requests for the requested session', async () => {
const sessionOneResponse = requestAcpPermission(permissionRequest('session-1', 'tool-1'));
const sessionTwoResponse = requestAcpPermission(permissionRequest('session-2', 'tool-2'));
cancelAcpPermissionRequestsForSession('session-1');
await expect(sessionOneResponse).resolves.toEqual({
outcome: {
outcome: 'cancelled',
},
});
await expectStillPending(sessionTwoResponse);
expect(resolveAcpPermissionRequest('session-2', 'tool-2', 'deny_once')).toBe(true);
await expect(sessionTwoResponse).resolves.toEqual({
outcome: {
outcome: 'selected',
optionId: 'reject-once',
},
});
});
it('cancels an older duplicate request for the same session and tool call', async () => {
const firstResponse = requestAcpPermission(permissionRequest('session-1', 'tool-1'));
const secondResponse = requestAcpPermission(permissionRequest('session-1', 'tool-1'));
await expect(firstResponse).resolves.toEqual({
outcome: {
outcome: 'cancelled',
},
});
await expectStillPending(secondResponse);
expect(resolveAcpPermissionRequest('session-1', 'tool-1', 'allow_once')).toBe(true);
await expect(secondResponse).resolves.toEqual({
outcome: {
outcome: 'selected',
optionId: 'allow-once',
},
});
});
});
+9 -3
View File
@@ -5,8 +5,12 @@ import {
} from '@aaif/goose-sdk';
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
import packageJson from '../../package.json';
import { routeAcpGooseSessionNotification, routeAcpSessionNotification } from './chatNotifications';
import {
handleAcpGooseSessionNotification,
handleAcpSessionNotification,
} from './chatNotifications';
import { createWebSocketStream } from './createWebSocketStream';
import { requestAcpElicitation } from './elicitationRequests';
import { requestAcpPermission } from './permissionRequests';
let clientPromise: Promise<GooseClient> | null = null;
@@ -15,8 +19,9 @@ let resolvedClient: GooseClient | null = null;
function createClientCallbacks(): () => GooseClientCallbacks {
return () => ({
requestPermission: requestAcpPermission,
sessionUpdate: routeAcpSessionNotification,
unstable_sessionUpdate: routeAcpGooseSessionNotification,
unstable_createElicitation: requestAcpElicitation,
sessionUpdate: handleAcpSessionNotification,
unstable_sessionUpdate: handleAcpGooseSessionNotification,
});
}
@@ -44,6 +49,7 @@ async function initializeConnection(): Promise<GooseClient> {
await client.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
elicitation: { form: {} },
_meta: {
goose: {
mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
@@ -0,0 +1,89 @@
import type { Message } from '../../api';
import type { AcpElicitationRequest } from '../elicitationRequests';
import {
type AcpChatStateChange,
type AdapterState,
DEFAULT_VISIBLE_MESSAGE_METADATA,
messagesChange,
} from './shared';
export type ElicitationStatus = 'submitted' | 'cancelled';
export function applyElicitationRequest(
state: AdapterState,
request: AcpElicitationRequest
): AcpChatStateChange[] {
if (hasExistingElicitation(state, request.id)) {
return messagesChange(state);
}
state.messages.push({
id: request.id,
role: 'assistant',
created: Math.floor(Date.now() / 1000),
content: [
{
type: 'actionRequired',
data: {
actionType: 'elicitation',
id: request.id,
message: request.request.message,
requested_schema: request.request.requestedSchema,
},
},
],
metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA },
});
return messagesChange(state);
}
export function applyElicitationStatus(
state: AdapterState,
elicitationId: string,
status: ElicitationStatus
): AcpChatStateChange[] {
const statusData = {
isSubmitted: status === 'submitted',
isCancelled: status === 'cancelled',
};
let changed = false;
state.messages = state.messages.map((message) => {
let messageChanged = false;
const content = message.content.map((content) => {
if (
content.type !== 'actionRequired' ||
content.data.actionType !== 'elicitation' ||
content.data.id !== elicitationId
) {
return content;
}
messageChanged = true;
changed = true;
return {
...content,
data: {
...content.data,
...statusData,
},
};
});
return messageChanged ? { ...message, content } : message;
});
return changed ? messagesChange(state) : [];
}
function hasExistingElicitation(state: AdapterState, elicitationId: string): boolean {
return state.messages.some((message: Message) =>
message.content.some(
(content) =>
content.type === 'actionRequired' &&
content.data.actionType === 'elicitation' &&
content.data.id === elicitationId
)
);
}
+15 -16
View File
@@ -1,21 +1,20 @@
import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk';
import type { SessionNotification } from '@agentclientprotocol/sdk';
import { createSessionScopedNotificationRouter } from './sessionScopedNotificationRouter';
import { USE_ACP_CHAT } from '../acpChatFeatureFlag';
import { acpChatSessionStore } from './chatSessionStore';
const acpSessionRouter = createSessionScopedNotificationRouter<SessionNotification>();
const gooseSessionRouter =
createSessionScopedNotificationRouter<GooseSessionNotification_unstable>();
export function handleAcpSessionNotification(notification: SessionNotification): Promise<void> {
if (USE_ACP_CHAT) {
acpChatSessionStore.applyAcpSessionNotification(notification);
}
return Promise.resolve();
}
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 (
export function handleAcpGooseSessionNotification(
notification: GooseSessionNotification_unstable
): Promise<void> => {
await gooseSessionRouter.route(notification);
};
): Promise<void> {
if (USE_ACP_CHAT) {
acpChatSessionStore.applyAcpGooseSessionNotification(notification);
}
return Promise.resolve();
}
+353
View File
@@ -0,0 +1,353 @@
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 {
createAcpSessionNotificationAdapter,
type AcpChatStateChange,
type AcpSessionNotificationAdapter,
} from './sessionNotificationAdapter';
import type { ElicitationStatus } from './adapter/elicitations';
import { cloneMessage } from './adapter/shared';
import type { AcpElicitationRequest } from './elicitationRequests';
export interface AcpChatSessionSnapshot {
session: Session | undefined;
messages: Message[];
tokenState: TokenState;
chatState: ChatState;
sessionLoadError: string | undefined;
activePromptAttemptId: string | null;
}
type SnapshotListener = (snapshot: AcpChatSessionSnapshot) => void;
interface StoreEntry extends AcpChatSessionSnapshot {
adapter: AcpSessionNotificationAdapter;
}
const initialTokenState: TokenState = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
accumulatedInputTokens: 0,
accumulatedOutputTokens: 0,
accumulatedTotalTokens: 0,
};
export interface AcpChatSessionStore {
getSnapshot(sessionId: string): AcpChatSessionSnapshot | undefined;
subscribe(sessionId: string, listener: (snapshot: AcpChatSessionSnapshot) => void): () => void;
deleteSnapshot(sessionId: string): void;
setLoadedSession(
sessionId: string,
session: Session,
tokenState?: TokenState
): AcpChatSessionSnapshot;
setSessionMetadata(sessionId: string, session: Session | undefined): AcpChatSessionSnapshot;
setMessages(sessionId: string, messages: Message[]): AcpChatSessionSnapshot;
setTokenState(sessionId: string, tokenState: TokenState): AcpChatSessionSnapshot;
setChatState(sessionId: string, chatState: ChatState): AcpChatSessionSnapshot;
setSessionLoadError(
sessionId: string,
sessionLoadError: string | undefined
): AcpChatSessionSnapshot;
startPromptAttempt(sessionId: string, promptAttemptId: string): AcpChatSessionSnapshot;
finishPromptAttemptIfCurrent(sessionId: string, promptAttemptId: string, error?: string): boolean;
clearActivePromptAttempt(sessionId: string): AcpChatSessionSnapshot | undefined;
isCurrentPromptAttempt(sessionId: string, promptAttemptId: string): boolean;
applyAcpSessionNotification(notification: SessionNotification): AcpChatSessionSnapshot;
applyAcpGooseSessionNotification(
notification: GooseSessionNotification_unstable
): AcpChatSessionSnapshot;
applyPermissionRequest(request: RequestPermissionRequest): AcpChatSessionSnapshot;
applyElicitationRequest(request: AcpElicitationRequest): AcpChatSessionSnapshot;
setElicitationStatus(
sessionId: string,
elicitationId: string,
status: ElicitationStatus
): AcpChatSessionSnapshot | undefined;
}
export function createAcpChatSessionStore(): AcpChatSessionStore {
const sessionsById = new Map<string, StoreEntry>();
const listenersBySessionId = new Map<string, Set<SnapshotListener>>();
const getSnapshot: AcpChatSessionStore['getSnapshot'] = (sessionId) => {
const entry = sessionsById.get(sessionId);
return entry ? snapshotFromEntry(entry) : undefined;
};
const subscribe: AcpChatSessionStore['subscribe'] = (sessionId, listener) => {
const listeners = listenersBySessionId.get(sessionId) ?? new Set<SnapshotListener>();
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 deleteSnapshot: AcpChatSessionStore['deleteSnapshot'] = (sessionId) => {
sessionsById.delete(sessionId);
};
const getOrCreateEntry = (sessionId: string): StoreEntry => {
const existing = sessionsById.get(sessionId);
if (existing) {
return existing;
}
const entry: StoreEntry = {
session: undefined,
messages: [],
tokenState: { ...initialTokenState },
chatState: ChatState.Idle,
sessionLoadError: undefined,
activePromptAttemptId: null,
adapter: createAcpSessionNotificationAdapter(),
};
sessionsById.set(sessionId, entry);
return entry;
};
const notify = (sessionId: string, entry: StoreEntry): AcpChatSessionSnapshot => {
const snapshot = snapshotFromEntry(entry);
const listeners = listenersBySessionId.get(sessionId);
if (listeners) {
for (const listener of listeners) {
listener(snapshot);
}
}
return snapshot;
};
const setLoadedSession: AcpChatSessionStore['setLoadedSession'] = (
sessionId,
session,
tokenState = tokenStateFromSession(session)
) => {
const entry = getOrCreateEntry(sessionId);
entry.session = session;
entry.messages = cloneMessages(session.conversation ?? []);
entry.tokenState = { ...tokenState };
entry.chatState = entry.activePromptAttemptId ? ChatState.Streaming : ChatState.Idle;
entry.sessionLoadError = undefined;
entry.adapter = createAcpSessionNotificationAdapter(entry.messages);
return notify(sessionId, entry);
};
const setSessionMetadata: AcpChatSessionStore['setSessionMetadata'] = (sessionId, session) => {
const entry = getOrCreateEntry(sessionId);
entry.session = session;
return notify(sessionId, entry);
};
const setMessages: AcpChatSessionStore['setMessages'] = (sessionId, messages) => {
const entry = getOrCreateEntry(sessionId);
entry.messages = cloneMessages(messages);
entry.adapter = createAcpSessionNotificationAdapter(entry.messages);
return notify(sessionId, entry);
};
const setTokenState: AcpChatSessionStore['setTokenState'] = (sessionId, tokenState) => {
const entry = getOrCreateEntry(sessionId);
entry.tokenState = { ...tokenState };
return notify(sessionId, entry);
};
const setChatState: AcpChatSessionStore['setChatState'] = (sessionId, chatState) => {
const entry = getOrCreateEntry(sessionId);
entry.chatState = chatState;
return notify(sessionId, entry);
};
const setSessionLoadError: AcpChatSessionStore['setSessionLoadError'] = (
sessionId,
sessionLoadError
) => {
const entry = getOrCreateEntry(sessionId);
entry.sessionLoadError = sessionLoadError;
return notify(sessionId, entry);
};
const startPromptAttempt: AcpChatSessionStore['startPromptAttempt'] = (
sessionId,
promptAttemptId
) => {
const entry = getOrCreateEntry(sessionId);
entry.activePromptAttemptId = promptAttemptId;
entry.chatState = ChatState.Streaming;
entry.sessionLoadError = undefined;
return notify(sessionId, entry);
};
const finishPromptAttemptIfCurrent: AcpChatSessionStore['finishPromptAttemptIfCurrent'] = (
sessionId,
promptAttemptId,
error
) => {
const entry = sessionsById.get(sessionId);
if (!entry || entry.activePromptAttemptId !== promptAttemptId) {
return false;
}
entry.activePromptAttemptId = null;
entry.chatState = ChatState.Idle;
entry.sessionLoadError = error;
notify(sessionId, entry);
return true;
};
const clearActivePromptAttempt: AcpChatSessionStore['clearActivePromptAttempt'] = (sessionId) => {
const entry = sessionsById.get(sessionId);
if (!entry) {
return undefined;
}
entry.activePromptAttemptId = null;
entry.chatState = ChatState.Idle;
return notify(sessionId, entry);
};
const isCurrentPromptAttempt: AcpChatSessionStore['isCurrentPromptAttempt'] = (
sessionId,
promptAttemptId
) => sessionsById.get(sessionId)?.activePromptAttemptId === promptAttemptId;
const applyAcpSessionNotification: AcpChatSessionStore['applyAcpSessionNotification'] = (
notification
) => {
const entry = getOrCreateEntry(notification.sessionId);
const changes = entry.adapter.apply(notification);
applyChatStateChanges(entry, changes);
return notify(notification.sessionId, entry);
};
const applyAcpGooseSessionNotification: AcpChatSessionStore['applyAcpGooseSessionNotification'] =
(notification) => {
const entry = getOrCreateEntry(notification.sessionId);
const changes = entry.adapter.applyGoose(notification);
applyChatStateChanges(entry, changes);
return notify(notification.sessionId, entry);
};
const applyPermissionRequest: AcpChatSessionStore['applyPermissionRequest'] = (request) => {
const entry = getOrCreateEntry(request.sessionId);
const changes = entry.adapter.applyPermissionRequest(request);
applyChatStateChanges(entry, changes);
entry.chatState = ChatState.WaitingForUserInput;
return notify(request.sessionId, entry);
};
const applyElicitationRequest: AcpChatSessionStore['applyElicitationRequest'] = (request) => {
const entry = getOrCreateEntry(request.sessionId);
const changes = entry.adapter.applyElicitationRequest(request);
applyChatStateChanges(entry, changes);
entry.chatState = ChatState.WaitingForUserInput;
return notify(request.sessionId, entry);
};
const setElicitationStatus: AcpChatSessionStore['setElicitationStatus'] = (
sessionId,
elicitationId,
status
) => {
const entry = sessionsById.get(sessionId);
if (!entry) {
return undefined;
}
const changes = entry.adapter.applyElicitationStatus(elicitationId, status);
if (changes.length === 0) {
return snapshotFromEntry(entry);
}
applyChatStateChanges(entry, changes);
return notify(sessionId, entry);
};
return {
getSnapshot,
subscribe,
deleteSnapshot,
setLoadedSession,
setSessionMetadata,
setMessages,
setTokenState,
setChatState,
setSessionLoadError,
startPromptAttempt,
finishPromptAttemptIfCurrent,
clearActivePromptAttempt,
isCurrentPromptAttempt,
applyAcpSessionNotification,
applyAcpGooseSessionNotification,
applyPermissionRequest,
applyElicitationRequest,
setElicitationStatus,
};
}
export const acpChatSessionStore = createAcpChatSessionStore();
export function tokenStateFromSession(session: Session | undefined): TokenState {
return {
inputTokens: session?.input_tokens ?? 0,
outputTokens: session?.output_tokens ?? 0,
totalTokens: session?.total_tokens ?? 0,
accumulatedInputTokens: session?.accumulated_input_tokens ?? 0,
accumulatedOutputTokens: session?.accumulated_output_tokens ?? 0,
accumulatedTotalTokens: session?.accumulated_total_tokens ?? 0,
...(session?.accumulated_cost !== undefined
? { accumulatedCost: session.accumulated_cost }
: {}),
};
}
function applyChatStateChanges(entry: StoreEntry, changes: AcpChatStateChange[]): void {
for (const change of changes) {
switch (change.type) {
case 'messages':
entry.messages = cloneMessages(change.messages);
break;
case 'tokenState':
entry.tokenState = { ...entry.tokenState, ...change.tokenState };
break;
case 'sessionInfo':
if (change.name && entry.session) {
entry.session = { ...entry.session, name: change.name };
}
break;
}
}
}
function snapshotFromEntry(entry: StoreEntry): AcpChatSessionSnapshot {
return {
session: entry.session,
messages: cloneMessages(entry.messages),
tokenState: { ...entry.tokenState },
chatState: entry.chatState,
sessionLoadError: entry.sessionLoadError,
activePromptAttemptId: entry.activePromptAttemptId,
};
}
function cloneMessages(messages: Message[]): Message[] {
return messages.map(cloneMessage);
}
+110
View File
@@ -0,0 +1,110 @@
import type {
CreateElicitationRequest,
CreateElicitationResponse,
ElicitationContentValue,
ElicitationSchema,
} from '@agentclientprotocol/sdk';
import { v7 as uuidv7 } from 'uuid';
import { USE_ACP_CHAT } from '../acpChatFeatureFlag';
import { acpChatSessionStore } from './chatSessionStore';
type SessionScopedFormElicitationRequest = CreateElicitationRequest & {
mode: 'form';
sessionId: string;
requestedSchema: ElicitationSchema;
};
export interface AcpElicitationRequest {
id: string;
sessionId: string;
request: SessionScopedFormElicitationRequest;
}
interface PendingElicitationRequest {
request: AcpElicitationRequest;
resolve: (response: CreateElicitationResponse) => void;
timeoutId: ReturnType<typeof setTimeout>;
}
const pendingRequests = new Map<string, PendingElicitationRequest>();
export const ACP_ELICITATION_TIMEOUT_SECONDS = 300;
export async function requestAcpElicitation(
request: CreateElicitationRequest
): Promise<CreateElicitationResponse> {
if (!USE_ACP_CHAT || !isSessionScopedFormElicitation(request)) {
return cancelledElicitationResponse();
}
const elicitationRequest: AcpElicitationRequest = {
id: `acp_elicitation_${uuidv7()}`,
sessionId: request.sessionId,
request,
};
const key = elicitationRequestKey(elicitationRequest.sessionId, elicitationRequest.id);
return new Promise<CreateElicitationResponse>((resolve) => {
const timeoutId = setTimeout(() => {
const pending = pendingRequests.get(key);
if (!pending) {
return;
}
pendingRequests.delete(key);
pending.resolve(cancelledElicitationResponse());
}, ACP_ELICITATION_TIMEOUT_SECONDS * 1000);
pendingRequests.set(key, { request: elicitationRequest, resolve, timeoutId });
acpChatSessionStore.applyElicitationRequest(elicitationRequest);
});
}
export function resolveAcpElicitationRequest(
sessionId: string,
elicitationId: string,
userData: Record<string, unknown>
): boolean {
const key = elicitationRequestKey(sessionId, elicitationId);
const pending = pendingRequests.get(key);
if (!pending) {
return false;
}
pendingRequests.delete(key);
clearTimeout(pending.timeoutId);
acpChatSessionStore.setElicitationStatus(sessionId, elicitationId, 'submitted');
pending.resolve(acceptedElicitationResponse(userData));
return true;
}
export function cancelAcpElicitationRequestsForSession(sessionId: string): void {
for (const [key, pending] of pendingRequests) {
if (pending.request.sessionId === sessionId) {
pendingRequests.delete(key);
clearTimeout(pending.timeoutId);
acpChatSessionStore.setElicitationStatus(sessionId, pending.request.id, 'cancelled');
pending.resolve(cancelledElicitationResponse());
}
}
}
function isSessionScopedFormElicitation(
request: CreateElicitationRequest
): request is SessionScopedFormElicitationRequest {
return request.mode === 'form' && 'sessionId' in request && typeof request.sessionId === 'string';
}
function acceptedElicitationResponse(userData: Record<string, unknown>): CreateElicitationResponse {
return {
action: 'accept',
content: userData as Record<string, ElicitationContentValue>,
};
}
function cancelledElicitationResponse(): CreateElicitationResponse {
return { action: 'cancel' };
}
function elicitationRequestKey(sessionId: string, elicitationId: string): string {
return `${sessionId}\u0000${elicitationId}`;
}
+7 -24
View File
@@ -1,17 +1,15 @@
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
import type { Permission } from '../api';
import { createSessionScopedNotificationRouter } from './sessionScopedNotificationRouter';
import { USE_ACP_CHAT } from '../acpChatFeatureFlag';
import { acpChatSessionStore } from './chatSessionStore';
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> {
@@ -21,28 +19,13 @@ export async function requestAcpPermission(
previous.resolve(cancelledPermissionResponse());
}
if (!USE_ACP_CHAT) {
return 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());
}
});
acpChatSessionStore.applyPermissionRequest(request);
});
}
@@ -1,11 +1,17 @@
import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk';
import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk';
import type { Message } from '../api';
import {
applyElicitationRequest as applyElicitationRequestToState,
applyElicitationStatus as applyElicitationStatusToState,
type ElicitationStatus,
} from './adapter/elicitations';
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';
import type { AcpElicitationRequest } from './elicitationRequests';
export type { AcpChatStateChange } from './adapter/shared';
@@ -13,6 +19,8 @@ export interface AcpSessionNotificationAdapter {
apply(notification: SessionNotification): AcpChatStateChange[];
applyGoose(notification: GooseSessionNotification_unstable): AcpChatStateChange[];
applyPermissionRequest(request: RequestPermissionRequest): AcpChatStateChange[];
applyElicitationRequest(request: AcpElicitationRequest): AcpChatStateChange[];
applyElicitationStatus(elicitationId: string, status: ElicitationStatus): AcpChatStateChange[];
getMessages(): Message[];
}
@@ -33,6 +41,12 @@ export function createAcpSessionNotificationAdapter(
applyPermissionRequest(request) {
return applyPermissionRequestToState(state, request);
},
applyElicitationRequest(request) {
return applyElicitationRequestToState(state, request);
},
applyElicitationStatus(elicitationId, status) {
return applyElicitationStatusToState(state, elicitationId, status);
},
getMessages() {
return state.messages.map(cloneMessage);
},
@@ -1,59 +0,0 @@
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,
};
}
+24 -3
View File
@@ -1,4 +1,8 @@
import type { ForkSessionRequest, ListSessionsRequest, SessionInfo } from '@agentclientprotocol/sdk';
import type {
ForkSessionRequest,
ListSessionsRequest,
SessionInfo,
} from '@agentclientprotocol/sdk';
import { getAcpClient } from './acpConnection';
import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext';
@@ -99,10 +103,27 @@ export async function acpRenameSession(sessionId: string, title: string): Promis
await client.goose.sessionRename_unstable({ sessionId, title });
}
export async function acpForkSession(sessionId: string, cwd: string): Promise<void> {
export async function acpTruncateSessionConversation(
sessionId: string,
truncateFrom: number
): Promise<void> {
const client = await getAcpClient();
await client.goose.sessionConversationTruncate_unstable({ sessionId, truncateFrom });
}
export async function acpForkSession(
sessionId: string,
conversationBefore?: number
): Promise<string> {
const client = await getAcpClient();
const sessionInfo = await client.goose.sessionInfo_unstable({ sessionId });
const { cwd } = sessionInfo.session;
const request: ForkSessionRequest = { sessionId, cwd };
await client.unstable_forkSession(request);
if (conversationBefore !== undefined) {
request._meta = { conversationBefore };
}
const response = await client.unstable_forkSession(request);
return String(response.sessionId);
}
export async function acpExportSession(sessionId: string): Promise<string> {
+2
View File
@@ -113,6 +113,7 @@ export default function BaseChat({
setRecipeUserParams,
tokenState,
notifications: toolCallNotifications,
pauseQueueOnStop,
onMessageUpdate,
} = useChatSession({
sessionId,
@@ -510,6 +511,7 @@ export default function BaseChat({
chatState={chatState}
setChatState={setChatState}
onStop={stopStreaming}
pauseQueueOnStop={pauseQueueOnStop}
commandHistory={commandHistory}
initialValue={initialPrompt}
setView={setView}
+13 -14
View File
@@ -30,11 +30,7 @@ import { DiagnosticsModal } from './ui/Diagnostics';
import { getSession, Message } from '../api';
import { getInitialWorkingDir } from '../utils/workingDir';
import { getPredefinedModelsFromEnv } from './settings/models/predefinedModelsUtils';
import {
trackFileAttached,
trackVoiceDictation,
trackDiagnosticsOpened,
} from '../utils/analytics';
import { trackFileAttached, trackVoiceDictation, trackDiagnosticsOpened } from '../utils/analytics';
import { getNavigationShortcutText } from '../utils/keyboardShortcuts';
import { UserInput, ImageData } from '../types/message';
import { compressImageDataUrl } from '../utils/conversionUtils';
@@ -51,9 +47,7 @@ const turndown = new TurndownService({
turndown.addRule('complexLinks', {
filter: (node) => {
return (
node.nodeName === 'A' &&
!!node.getAttribute('href') &&
/\n/.test(node.textContent || '')
node.nodeName === 'A' && !!node.getAttribute('href') && /\n/.test(node.textContent || '')
);
},
replacement: (content, node) => {
@@ -178,6 +172,7 @@ interface ChatInputProps {
chatState: ChatState;
setChatState?: (state: ChatState) => void;
onStop?: () => void;
pauseQueueOnStop?: boolean;
commandHistory?: string[];
initialValue?: string;
droppedFiles?: DroppedFile[];
@@ -209,6 +204,7 @@ export default function ChatInput({
chatState = ChatState.Idle,
setChatState,
onStop,
pauseQueueOnStop = false,
commandHistory = [],
initialValue = '',
droppedFiles = [],
@@ -1378,6 +1374,13 @@ export default function ChatInput({
if (onStop) onStop();
};
const handleStop = () => {
if (pauseQueueOnStop && queuedMessages.length > 0) {
pauseRemainingQueue();
}
if (onStop) onStop();
};
const handleResumeQueue = () => {
queuePausedRef.current = false;
setLastInterruption(null);
@@ -1588,10 +1591,7 @@ export default function ChatInput({
extensions, diagnostics, attach, mic, send. When the bar is narrow
(e.g. on a small window), the secondary controls drop out so the
model selector + send button always stay visible. */}
<div
ref={bottomBarRef}
className="flex flex-row items-center gap-2 px-3 py-2 relative"
>
<div ref={bottomBarRef} className="flex flex-row items-center gap-2 px-3 py-2 relative">
{/* Left: model selector */}
<Tooltip>
<div>
@@ -1746,7 +1746,7 @@ export default function ChatInput({
{isLoading && !hasSubmittableContent ? (
<Button
type="button"
onClick={onStop}
onClick={handleStop}
size="sm"
shape="round"
variant="ghost"
@@ -1804,7 +1804,6 @@ export default function ChatInput({
}
workingDir={sessionWorkingDir ?? getInitialWorkingDir()}
/>
</div>
</div>
);
@@ -0,0 +1,82 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { ActionRequired } from '../api';
import { IntlTestWrapper } from '../i18n/test-utils';
import ElicitationRequest from './ElicitationRequest';
const actionRequiredContent = {
type: 'actionRequired',
data: {
actionType: 'elicitation',
id: 'elicitation-1',
message: 'Need more information',
requested_schema: {
type: 'object',
properties: {},
},
},
} as ActionRequired & { type: 'actionRequired' };
type SubmitElicitationResponse = (
elicitationId: string,
userData: Record<string, unknown>
) => Promise<boolean>;
function renderElicitationRequest(onSubmit: SubmitElicitationResponse) {
return render(
<ElicitationRequest
isCancelledMessage={false}
isClicked={false}
actionRequiredContent={actionRequiredContent}
onSubmit={onSubmit}
/>,
{ wrapper: IntlTestWrapper }
);
}
describe('ElicitationRequest', () => {
it('shows submitted state when the response is accepted', async () => {
const onSubmit = vi.fn<SubmitElicitationResponse>().mockResolvedValue(true);
renderElicitationRequest(onSubmit);
await userEvent.click(screen.getByRole('button', { name: 'Accept' }));
expect(onSubmit).toHaveBeenCalledWith('elicitation-1', {});
expect(await screen.findByText('Information submitted')).toBeInTheDocument();
});
it('shows submitted state while the response is pending', async () => {
let resolveSubmission: (value: boolean) => void = () => {};
const submission = new Promise<boolean>((resolve) => {
resolveSubmission = resolve;
});
const onSubmit = vi.fn<SubmitElicitationResponse>().mockReturnValue(submission);
renderElicitationRequest(onSubmit);
await userEvent.click(screen.getByRole('button', { name: 'Accept' }));
expect(screen.getByText('Information submitted')).toBeInTheDocument();
await act(async () => {
resolveSubmission(true);
await submission;
});
});
it('keeps the request actionable when no ACP request is pending', async () => {
const onSubmit = vi.fn<SubmitElicitationResponse>().mockResolvedValue(false);
renderElicitationRequest(onSubmit);
await userEvent.click(screen.getByRole('button', { name: 'Accept' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'This request is no longer active. The extension will need to ask again.'
);
expect(screen.queryByText('Information submitted')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Accept' })).toBeEnabled();
});
});
@@ -34,6 +34,10 @@ const i18n = defineMessages({
id: 'elicitationRequest.waitingForResponse',
defaultMessage: 'Waiting for your response ({timeRemaining} remaining)',
},
submitError: {
id: 'elicitationRequest.submitError',
defaultMessage: 'This request is no longer active. The extension will need to ask again.',
},
});
const ELICITATION_TIMEOUT_SECONDS = 300;
@@ -42,7 +46,7 @@ interface ElicitationRequestProps {
isCancelledMessage: boolean;
isClicked: boolean;
actionRequiredContent: ActionRequired & { type: 'actionRequired' };
onSubmit: (elicitationId: string, userData: Record<string, unknown>) => void;
onSubmit: (elicitationId: string, userData: Record<string, unknown>) => Promise<boolean>;
}
function formatTime(seconds: number): string {
@@ -59,9 +63,18 @@ export default function ElicitationRequest({
}: ElicitationRequestProps) {
const intl = useIntl();
const [submitted, setSubmitted] = useState(isClicked);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | undefined>();
const [timeRemaining, setTimeRemaining] = useState(ELICITATION_TIMEOUT_SECONDS);
const startTimeRef = useRef(Date.now());
useEffect(() => {
if (isClicked) {
setSubmitted(true);
setSubmitError(undefined);
}
}, [isClicked]);
useEffect(() => {
if (submitted || isCancelledMessage || isClicked) return;
@@ -87,14 +100,31 @@ export default function ElicitationRequest({
const schema = (requested_schema ?? {}) as JsonSchema;
const hasSchemaFields = Boolean(schema.properties && Object.keys(schema.properties).length > 0);
const handleSubmit = (formData: Record<string, unknown>) => {
const submitResponse = async (formData: Record<string, unknown>) => {
setSubmitted(true);
onSubmit(elicitationId, formData);
setIsSubmitting(true);
setSubmitError(undefined);
try {
const didSubmit = await onSubmit(elicitationId, formData);
if (!didSubmit) {
setSubmitted(false);
setSubmitError(intl.formatMessage(i18n.submitError));
}
} catch (error) {
console.error('Error submitting elicitation response:', error);
setSubmitted(false);
setSubmitError(intl.formatMessage(i18n.submitError));
} finally {
setIsSubmitting(false);
}
};
const handleSubmit = (formData: Record<string, unknown>) => {
void submitResponse(formData);
};
const handleAccept = () => {
setSubmitted(true);
onSubmit(elicitationId, {});
void submitResponse({});
};
if (isCancelledMessage) {
@@ -165,14 +195,20 @@ export default function ElicitationRequest({
schema={schema}
onSubmit={handleSubmit}
submitLabel={intl.formatMessage(i18n.submit)}
disabled={isSubmitting}
/>
) : (
<div className="flex gap-2">
<Button type="button" onClick={handleAccept}>
<Button type="button" onClick={handleAccept} disabled={isSubmitting}>
{intl.formatMessage(i18n.accept)}
</Button>
</div>
)}
{submitError && (
<div role="alert" className="mt-3 text-sm text-red-500">
{submitError}
</div>
)}
<div
className={`mt-3 pt-3 border-t border-border-primary flex items-center gap-2 text-sm ${isUrgent ? 'text-red-500' : 'text-text-secondary'}`}
>
+10 -3
View File
@@ -34,7 +34,7 @@ interface GooseMessageProps {
submitElicitationResponse?: (
elicitationId: string,
userData: Record<string, unknown>
) => Promise<void>;
) => Promise<boolean>;
}
export default function GooseMessage({
@@ -75,6 +75,13 @@ export default function GooseMessage({
);
const hasToolConfirmation = toolConfirmationContent !== undefined;
const hasElicitation = elicitationContent !== undefined;
const elicitationData =
elicitationContent?.data.actionType === 'elicitation'
? (elicitationContent.data as typeof elicitationContent.data & {
isSubmitted?: boolean;
isCancelled?: boolean;
})
: undefined;
const toolConfirmationShownInline = useMemo(() => {
if (!toolConfirmationContent) return false;
@@ -203,8 +210,8 @@ export default function GooseMessage({
{hasElicitation && submitElicitationResponse && (
<ElicitationRequest
isCancelledMessage={false}
isClicked={false}
isCancelledMessage={elicitationData?.isCancelled === true}
isClicked={elicitationData?.isSubmitted === true}
actionRequiredContent={elicitationContent}
onSubmit={submitElicitationResponse}
/>
@@ -65,7 +65,7 @@ interface ProgressiveMessageListProps {
submitElicitationResponse?: (
elicitationId: string,
userData: Record<string, unknown>
) => Promise<void>;
) => Promise<boolean>;
}
export default function ProgressiveMessageList({
@@ -45,6 +45,9 @@ import {
acpRenameSession,
type SessionListItem,
} from '../../acp/sessions';
import { acpChatSessionStore } from '../../acp/chatSessionStore';
import { cancelAcpPermissionRequestsForSession } from '../../acp/permissionRequests';
import { cancelAcpElicitationRequestsForSession } from '../../acp/elicitationRequests';
import { getSearchShortcutText } from '../../utils/keyboardShortcuts';
import { clearSessionCache } from '../../hooks/useChatStream';
@@ -482,7 +485,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
const handleDuplicateSession = useCallback(
async (session: SessionListItem) => {
try {
await acpForkSession(session.id, session.workingDir);
await acpForkSession(session.id);
toast.success(intl.formatMessage(i18n.duplicateSuccess, { name: session.name }));
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
await loadSessions();
@@ -509,6 +512,9 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
new CustomEvent(AppEvents.SESSION_DELETED, { detail: { sessionId: sessionToDeleteId } })
);
clearSessionCache(sessionToDeleteId);
cancelAcpPermissionRequestsForSession(sessionToDeleteId);
cancelAcpElicitationRequestsForSession(sessionToDeleteId);
acpChatSessionStore.deleteSnapshot(sessionToDeleteId);
} catch (error) {
console.error('Error deleting session:', error);
toast.error(intl.formatMessage(i18n.deleteFailed, { name: sessionName, error: errorMessage(error, 'Unknown error') }));
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -17,12 +17,13 @@ export interface UseChatSessionResult {
submitElicitationResponse: (
elicitationId: string,
userData: Record<string, unknown>
) => Promise<void>;
) => Promise<boolean>;
setRecipeUserParams: (values: Record<string, string>) => Promise<void>;
stopStreaming: () => void;
sessionLoadError?: string;
tokenState: TokenState;
notifications: Map<string, NotificationEvent[]>;
pauseQueueOnStop: boolean;
onMessageUpdate: (
messageId: string,
newContent: string,
+4 -1
View File
@@ -929,7 +929,7 @@ export function useChatStream({
const currentState = stateRef.current;
if (!currentState.session || currentState.chatState === ChatState.LoadingConversation) {
return;
return true;
}
// An elicitation response unblocks an in-flight tool call on the original
@@ -947,8 +947,10 @@ export function useChatStream({
},
throwOnError: true,
});
return true;
} catch (error) {
onFinish('Submit error: ' + errorMessage(error));
return true;
}
},
[sessionId, onFinish]
@@ -1141,6 +1143,7 @@ export function useChatStream({
setRecipeUserParams,
tokenState: state.tokenState,
notifications: notificationsMap,
pauseQueueOnStop: false,
onMessageUpdate,
};
}
+3
View File
@@ -992,6 +992,9 @@
"elicitationRequest.submit": {
"defaultMessage": "Submit"
},
"elicitationRequest.submitError": {
"defaultMessage": "This request is no longer active. The extension will need to ask again."
},
"elicitationRequest.submitted": {
"defaultMessage": "Information submitted"
},
+3
View File
@@ -992,6 +992,9 @@
"elicitationRequest.submit": {
"defaultMessage": "Enviar"
},
"elicitationRequest.submitError": {
"defaultMessage": "This request is no longer active. The extension will need to ask again."
},
"elicitationRequest.submitted": {
"defaultMessage": "Información enviada"
},
+3
View File
@@ -992,6 +992,9 @@
"elicitationRequest.submit": {
"defaultMessage": "सबमिट करें"
},
"elicitationRequest.submitError": {
"defaultMessage": "This request is no longer active. The extension will need to ask again."
},
"elicitationRequest.submitted": {
"defaultMessage": "जानकारी प्रस्तुत की गई"
},
+3
View File
@@ -992,6 +992,9 @@
"elicitationRequest.submit": {
"defaultMessage": "送信"
},
"elicitationRequest.submitError": {
"defaultMessage": "This request is no longer active. The extension will need to ask again."
},
"elicitationRequest.submitted": {
"defaultMessage": "情報を送信しました"
},
+3
View File
@@ -992,6 +992,9 @@
"elicitationRequest.submit": {
"defaultMessage": "Отправить"
},
"elicitationRequest.submitError": {
"defaultMessage": "This request is no longer active. The extension will need to ask again."
},
"elicitationRequest.submitted": {
"defaultMessage": "Информация отправлена"
},
+3
View File
@@ -992,6 +992,9 @@
"elicitationRequest.submit": {
"defaultMessage": "Gönder"
},
"elicitationRequest.submitError": {
"defaultMessage": "This request is no longer active. The extension will need to ask again."
},
"elicitationRequest.submitted": {
"defaultMessage": "Bilgiler gönderildi"
},
+3
View File
@@ -992,6 +992,9 @@
"elicitationRequest.submit": {
"defaultMessage": "提交"
},
"elicitationRequest.submitError": {
"defaultMessage": "This request is no longer active. The extension will need to ask again."
},
"elicitationRequest.submitted": {
"defaultMessage": "信息已提交"
},
+10
View File
@@ -101,6 +101,7 @@ import type {
SetSessionSystemPromptRequest_unstable,
SteerSessionRequest_unstable,
SteerSessionResponse_unstable,
TruncateSessionConversationRequest_unstable,
UnarchiveSessionRequest_unstable,
UpdateSessionProjectRequest_unstable,
UpdateSourceRequest_unstable,
@@ -575,6 +576,15 @@ export class GooseExtClient {
) as GetSessionInfoResponse_unstable;
}
async sessionConversationTruncate_unstable(
params: TruncateSessionConversationRequest_unstable,
): Promise<void> {
await this.conn.extMethod(
"_goose/unstable/session/conversation/truncate",
params,
);
}
async sessionProjectUpdate_unstable(
params: UpdateSessionProjectRequest_unstable,
): Promise<void> {
+6 -1
View File
@@ -1,6 +1,6 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, ResourceLink, Role, SessionId, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js';
export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, ResourceLink, Role, SessionId, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js';
export const GOOSE_EXT_METHODS = [
{
@@ -203,6 +203,11 @@ export const GOOSE_EXT_METHODS = [
requestType: "GetSessionInfoRequest_unstable",
responseType: "GetSessionInfoResponse_unstable",
},
{
method: "_goose/unstable/session/conversation/truncate",
requestType: "TruncateSessionConversationRequest_unstable",
responseType: "EmptyResponse",
},
{
method: "_goose/unstable/session/project/update",
requestType: "UpdateSessionProjectRequest_unstable",
+9 -1
View File
@@ -1197,6 +1197,14 @@ export type SessionInfo = {
*/
export type SessionId = string;
/**
* Truncate a session conversation from the given message timestamp onward.
*/
export type TruncateSessionConversationRequest_unstable = {
sessionId: string;
truncateFrom: number;
};
/**
* Update the project association for a session.
*/
@@ -1594,7 +1602,7 @@ export type StatusMessageUpdate = {
export type ExtRequest = {
id: string;
method: string;
params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | GetSessionInfoRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | {
params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | {
[key: string]: unknown;
} | null;
};
+9
View File
@@ -1173,6 +1173,14 @@ export const zGetSessionInfoResponse_unstable = z.object({
session: zSessionInfo
});
/**
* Truncate a session conversation from the given message timestamp onward.
*/
export const zTruncateSessionConversationRequest_unstable = z.object({
sessionId: z.string(),
truncateFrom: z.number().int()
});
/**
* Update the project association for a session.
*/
@@ -1605,6 +1613,7 @@ export const zExtRequest = z.object({
zExportSessionRequest_unstable,
zImportSessionRequest_unstable,
zGetSessionInfoRequest_unstable,
zTruncateSessionConversationRequest_unstable,
zUpdateSessionProjectRequest_unstable,
zRenameSessionRequest_unstable,
zArchiveSessionRequest_unstable,