refactor: goose 2 ui used acp session id (#8985)

This commit is contained in:
Lifei Zhou
2026-05-05 12:40:34 +10:00
committed by GitHub
parent 2fe4c3d0bb
commit de471bc2b2
30 changed files with 319 additions and 735 deletions
+1 -8
View File
@@ -107,14 +107,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const t1 = performance.now();
perfLog(`[perf:load] ${sid} import in ${(t1 - t0).toFixed(1)}ms`);
const session = useChatSessionStore.getState().getSession(sessionId);
const gooseSessionId = session?.acpSessionId ?? sessionId;
const project = session?.projectId
? (useProjectStore
.getState()
.projects.find((p) => p.id === session.projectId) ?? null)
: null;
const workingDir = await resolveSessionCwd(project);
await acpLoadSession(sessionId, gooseSessionId, workingDir);
await acpLoadSession(sessionId, workingDir);
const tFlush = performance.now();
useChatStore.getState().setSessionLoading(sessionId, false);
const buffer = getAndDeleteReplayBuffer(sessionId);
@@ -197,9 +196,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
homeSession.id,
sessionModelPreference.providerId,
workingDir,
{
personaId: homeSession.personaId,
},
);
const shouldClearHomeModel =
sessionModelPreference.providerId !== homeSession.providerId ||
@@ -468,9 +464,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
sessionId,
session.providerId ?? agentStore.selectedProvider ?? "goose",
workingDir,
{
personaId: session.personaId,
},
);
})().catch((error) => {
console.error(
@@ -6,7 +6,6 @@ import { clearReplayBuffer, ensureReplayBuffer } from "../replayBuffer";
const mockAcpSendMessage = vi.fn();
const mockAcpLoadSession = vi.fn();
const mockGetGooseSessionId = vi.fn();
vi.mock("@/shared/api/acp", () => ({
acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args),
@@ -16,10 +15,6 @@ vi.mock("@/shared/api/acp", () => ({
acpSetModel: vi.fn(),
}));
vi.mock("@/shared/api/acpSessionTracker", () => ({
getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args),
}));
import { useChat } from "../useChat";
function createDeferredPromise<T = void>() {
@@ -51,7 +46,6 @@ describe("useChat compaction", () => {
beforeEach(() => {
mockAcpSendMessage.mockReset();
mockAcpLoadSession.mockReset();
mockGetGooseSessionId.mockReset();
clearReplayBuffer("session-1");
useChatStore.setState({
messagesBySession: {},
@@ -62,11 +56,9 @@ describe("useChat compaction", () => {
});
mockAcpSendMessage.mockResolvedValue(undefined);
mockAcpLoadSession.mockResolvedValue(undefined);
mockGetGooseSessionId.mockReturnValue(null);
});
it("reloads compacted history after sending the compact command", async () => {
mockGetGooseSessionId.mockReturnValue("goose-session-1");
mockAcpLoadSession.mockImplementation(async (sessionId: string) => {
const buffer = ensureReplayBuffer(sessionId);
buffer.push(createTextMessage("user-1", "user", "Before compact"));
@@ -93,11 +85,7 @@ describe("useChat compaction", () => {
"/compact",
undefined,
);
expect(mockAcpLoadSession).toHaveBeenCalledWith(
"session-1",
"goose-session-1",
undefined,
);
expect(mockAcpLoadSession).toHaveBeenCalledWith("session-1", undefined);
const messages = useChatStore.getState().messagesBySession["session-1"];
const runtime = useChatStore.getState().getSessionRuntime("session-1");
@@ -135,12 +123,6 @@ describe("useChat compaction", () => {
const ensurePrepared = vi.fn(async (personaId?: string) => {
preparedPersonaId = personaId;
});
mockGetGooseSessionId.mockImplementation(
(_sessionId: string, personaId?: string) =>
personaId === "persona-a" && preparedPersonaId === "persona-a"
? "goose-session-a"
: null,
);
const { result } = renderHook(() =>
useChat(
@@ -160,15 +142,11 @@ describe("useChat compaction", () => {
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "/compact", {
personaId: "persona-a",
});
expect(mockAcpLoadSession).toHaveBeenCalledWith(
"session-1",
"goose-session-a",
undefined,
);
expect(mockAcpLoadSession).toHaveBeenCalledWith("session-1", undefined);
expect(preparedPersonaId).toBe("persona-a");
});
it("blocks new sends while compaction is in flight", async () => {
mockGetGooseSessionId.mockReturnValue("goose-session-1");
const compactDeferred = createDeferredPromise();
mockAcpSendMessage.mockImplementation(
(_sessionId: string, prompt: string) =>
@@ -215,7 +193,6 @@ describe("useChat compaction", () => {
});
it("ignores a second compact request while the first one is still in flight", async () => {
mockGetGooseSessionId.mockReturnValue("goose-session-1");
const compactDeferred = createDeferredPromise();
mockAcpSendMessage.mockImplementation(
(_sessionId: string, prompt: string) =>
@@ -254,13 +231,22 @@ describe("useChat compaction", () => {
).toBe("idle");
});
it("surfaces an error when compacting before the session is prepared", async () => {
const { result } = renderHook(() => useChat("session-1"));
it("surfaces an error when preparing for compaction fails", async () => {
const ensurePrepared = vi
.fn()
.mockRejectedValue(new Error("prepare failed"));
const { result } = renderHook(() =>
useChat("session-1", undefined, undefined, undefined, {
ensurePrepared,
}),
);
await act(async () => {
await result.current.compactConversation();
});
expect(ensurePrepared).toHaveBeenCalledWith(undefined);
expect(mockAcpSendMessage).not.toHaveBeenCalled();
expect(mockAcpLoadSession).not.toHaveBeenCalled();
@@ -272,11 +258,10 @@ describe("useChat compaction", () => {
{
type: "systemNotification",
notificationType: "error",
text: "Session not prepared. Send a message before compacting.",
text: "prepare failed",
},
]);
expect(runtime.error).toBe(
"Session not prepared. Send a message before compacting.",
);
expect(runtime.error).toBe("prepare failed");
expect(runtime.chatState).toBe("idle");
});
});
@@ -8,7 +8,6 @@ import { clearReplayBuffer } from "../replayBuffer";
const mockAcpSendMessage = vi.fn();
const mockAcpCancelSession = vi.fn();
const mockAcpLoadSession = vi.fn();
const mockGetGooseSessionId = vi.fn();
vi.mock("@/shared/api/acp", () => ({
acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args),
@@ -16,10 +15,6 @@ vi.mock("@/shared/api/acp", () => ({
acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args),
}));
vi.mock("@/shared/api/acpSessionTracker", () => ({
getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args),
}));
import { useChat } from "../useChat";
describe("useChat persona preparation", () => {
@@ -27,7 +22,6 @@ describe("useChat persona preparation", () => {
mockAcpSendMessage.mockReset();
mockAcpCancelSession.mockReset();
mockAcpLoadSession.mockReset();
mockGetGooseSessionId.mockReset();
clearReplayBuffer("session-1");
useChatStore.setState({
messagesBySession: {},
@@ -72,7 +66,6 @@ describe("useChat persona preparation", () => {
mockAcpSendMessage.mockResolvedValue(undefined);
mockAcpCancelSession.mockResolvedValue(true);
mockAcpLoadSession.mockResolvedValue(undefined);
mockGetGooseSessionId.mockReturnValue(null);
});
it("prepares the override persona before prompting", async () => {
@@ -9,7 +9,6 @@ const mockAcpCancelSession = vi.fn();
const mockAcpLoadSession = vi.fn();
const mockAcpPrepareSession = vi.fn();
const mockAcpSetModel = vi.fn();
const mockGetGooseSessionId = vi.fn();
vi.mock("@/shared/api/acp", () => ({
acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args),
@@ -19,10 +18,6 @@ vi.mock("@/shared/api/acp", () => ({
acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args),
}));
vi.mock("@/shared/api/acpSessionTracker", () => ({
getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args),
}));
import { useChat } from "../useChat";
describe("useChat skill chips", () => {
@@ -32,9 +27,7 @@ describe("useChat skill chips", () => {
mockAcpLoadSession.mockReset();
mockAcpPrepareSession.mockReset();
mockAcpSetModel.mockReset();
mockGetGooseSessionId.mockReset();
mockAcpSendMessage.mockResolvedValue(undefined);
mockGetGooseSessionId.mockReturnValue(null);
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
@@ -11,7 +11,6 @@ const mockAcpCancelSession = vi.fn();
const mockAcpLoadSession = vi.fn();
const mockAcpPrepareSession = vi.fn();
const mockAcpSetModel = vi.fn();
const mockGetGooseSessionId = vi.fn();
vi.mock("@/shared/api/acp", () => ({
acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args),
@@ -21,10 +20,6 @@ vi.mock("@/shared/api/acp", () => ({
acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args),
}));
vi.mock("@/shared/api/acpSessionTracker", () => ({
getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args),
}));
import { useChat } from "../useChat";
function addStreamingAssistantMessage(
@@ -66,7 +61,6 @@ describe("useChat", () => {
mockAcpLoadSession.mockReset();
mockAcpPrepareSession.mockReset();
mockAcpSetModel.mockReset();
mockGetGooseSessionId.mockReset();
clearReplayBuffer("session-1");
clearReplayBuffer("session-2");
useChatStore.setState({
@@ -115,96 +109,6 @@ describe("useChat", () => {
mockAcpLoadSession.mockResolvedValue(undefined);
mockAcpPrepareSession.mockResolvedValue(undefined);
mockAcpSetModel.mockResolvedValue(undefined);
mockGetGooseSessionId.mockReturnValue(null);
});
it("cancels the active override persona instead of the hook default persona", async () => {
const deferred = createDeferredPromise();
mockAcpSendMessage.mockReturnValue(deferred.promise);
const { result } = renderHook(() =>
useChat("session-1", undefined, undefined, {
id: "persona-a",
name: "Persona A",
}),
);
let sendPromise!: Promise<void>;
await act(async () => {
sendPromise = result.current.sendMessage("Hello", {
id: "persona-b",
name: "Persona B",
});
await Promise.resolve();
});
act(() => {
result.current.stopGeneration();
});
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "Hello", {
systemPrompt: undefined,
personaId: "persona-b",
personaName: "Persona B",
images: undefined,
});
expect(mockAcpCancelSession).toHaveBeenCalledWith("session-1", "persona-b");
deferred.resolve();
await act(async () => {
await sendPromise;
});
});
it("keeps persona-aware cancellation working after remount", async () => {
const deferred = createDeferredPromise();
mockAcpSendMessage.mockReturnValue(deferred.promise);
const firstMount = renderHook(() =>
useChat("session-1", undefined, undefined, {
id: "persona-a",
name: "Persona A",
}),
);
let sendPromise!: Promise<void>;
await act(async () => {
sendPromise = firstMount.result.current.sendMessage("Hello", {
id: "persona-b",
name: "Persona B",
});
await Promise.resolve();
});
act(() => {
addStreamingAssistantMessage(
"session-1",
"assistant-1",
"persona-b",
"Persona B",
);
});
act(() => {
firstMount.unmount();
});
const secondMount = renderHook(() =>
useChat("session-1", undefined, undefined, {
id: "persona-a",
name: "Persona A",
}),
);
act(() => {
secondMount.result.current.stopGeneration();
});
expect(mockAcpCancelSession).toHaveBeenCalledWith("session-1", "persona-b");
deferred.resolve();
await act(async () => {
await sendPromise;
});
});
it("marks the streaming message stopped only after cancellation succeeds", async () => {
@@ -194,7 +194,6 @@ describe("useChatSessionController", () => {
"session-1",
"anthropic",
"/tmp/project",
{ personaId: undefined },
);
});
@@ -342,7 +341,6 @@ describe("useChatSessionController", () => {
"session-2",
"anthropic",
"/tmp/project",
{ personaId: undefined },
);
});
+17 -50
View File
@@ -13,7 +13,6 @@ import {
acpCancelSession,
acpLoadSession,
} from "@/shared/api/acp";
import { getGooseSessionId } from "@/shared/api/acpSessionTracker";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import {
getSessionTitleFromDraft,
@@ -107,24 +106,12 @@ export function useChat(
) {
const store = useChatStore();
const abortRef = useRef<AbortController | null>(null);
const streamingPersonaIdRef = useRef<string | null>(null);
const messages = store.messagesBySession[sessionId] ?? [];
const { chatState, tokenState, error, streamingMessageId } =
store.getSessionRuntime(sessionId);
const isStreaming = chatState === "streaming" || streamingMessageId !== null;
const getStreamingPersonaId = useCallback(() => {
if (!streamingMessageId) {
return null;
}
return (
messages.find((message) => message.id === streamingMessageId)?.metadata
?.personaId ?? null
);
}, [messages, streamingMessageId]);
const resolvePersonaInfo = useCallback(
(overridePersonaId?: string, overridePersonaName?: string) => {
if (overridePersonaId) {
@@ -237,7 +224,6 @@ export function useChat(
const abort = new AbortController();
abortRef.current = abort;
streamingPersonaIdRef.current = effectivePersonaInfo?.id ?? null;
try {
await options?.ensurePrepared?.(effectivePersonaInfo?.id);
@@ -302,7 +288,6 @@ export function useChat(
store.setPendingAssistantProvider(sessionId, null);
} finally {
abortRef.current = null;
streamingPersonaIdRef.current = null;
}
},
[
@@ -317,8 +302,6 @@ export function useChat(
const stopGeneration = useCallback(() => {
abortRef.current?.abort();
const activePersonaId =
streamingPersonaIdRef.current ?? getStreamingPersonaId();
const activeStreamingMessageId = useChatStore
.getState()
.getSessionRuntime(sessionId).streamingMessageId;
@@ -327,7 +310,7 @@ export function useChat(
store.setStreamingMessageId(sessionId, null);
store.setPendingAssistantProvider(sessionId, null);
// Cancel the backend ACP session to stop orphaned streaming events
acpCancelSession(sessionId, activePersonaId ?? undefined)
acpCancelSession(sessionId)
.then((wasCancelled) => {
if (wasCancelled && activeStreamingMessageId) {
markMessageStopped(sessionId, activeStreamingMessageId);
@@ -336,7 +319,7 @@ export function useChat(
.catch(() => {
// Best-effort cancellation — ignore errors
});
}, [getStreamingPersonaId, store, sessionId]);
}, [store, sessionId]);
const retryLastMessage = useCallback(async () => {
const sessionMessages = store.messagesBySession[sessionId] ?? [];
@@ -400,41 +383,25 @@ export function useChat(
overridePersona?.id,
overridePersona?.name,
);
let gooseSessionId = getGooseSessionId(
sessionId,
effectivePersonaInfo?.id,
);
if (!gooseSessionId) {
try {
await options?.ensurePrepared?.(effectivePersonaInfo?.id);
} catch (err) {
const errorMessage = getErrorMessage(err);
store.addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
return "failed" as CompactConversationResult;
}
gooseSessionId = getGooseSessionId(sessionId, effectivePersonaInfo?.id);
}
if (!gooseSessionId) {
const errorMessage =
"Session not prepared. Send a message before compacting.";
store.addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
return "failed" as CompactConversationResult;
}
store.setActiveSession(sessionId);
store.setChatState(sessionId, "compacting");
store.setStreamingMessageId(sessionId, null);
store.setError(sessionId, null);
try {
await options?.ensurePrepared?.(effectivePersonaInfo?.id);
} catch (err) {
const errorMessage = getErrorMessage(err);
store.addMessage(
sessionId,
createSystemNotificationMessage(errorMessage, "error"),
);
store.setError(sessionId, errorMessage);
store.setChatState(sessionId, "idle");
return "failed" as CompactConversationResult;
}
store.setSessionLoading(sessionId, true);
clearReplayBuffer(sessionId);
@@ -449,7 +416,7 @@ export function useChat(
// transient chunks and refresh the session from replay instead.
clearReplayBuffer(sessionId);
const workingDir = getWorkingDir();
await acpLoadSession(sessionId, gooseSessionId, workingDir);
await acpLoadSession(sessionId, workingDir);
store.setSessionLoading(sessionId, false);
@@ -157,7 +157,6 @@ export function useChatSessionController({
providerId: string,
nextProject = project,
nextWorkspacePath = activeWorkspace?.path,
personaId = selectedPersonaId ?? undefined,
modelSelection?: PreferredModelSelection | null,
) => {
if (!sessionId) {
@@ -167,7 +166,7 @@ export function useChatSessionController({
nextProject,
nextWorkspacePath,
);
await acpPrepareSession(sessionId, providerId, workingDir, { personaId });
await acpPrepareSession(sessionId, providerId, workingDir);
if (!modelSelection?.id) {
return;
}
@@ -188,7 +187,7 @@ export function useChatSessionController({
modelName: modelSelection.name,
});
},
[activeWorkspace?.path, project, selectedPersonaId, sessionId],
[activeWorkspace?.path, project, sessionId],
);
const prepareSelectedProvider = useCallback(
(providerId: string, modelSelection?: PreferredModelSelection | null) =>
@@ -196,10 +195,9 @@ export function useChatSessionController({
providerId,
project,
activeWorkspace?.path,
selectedPersonaId ?? undefined,
modelSelection,
),
[activeWorkspace?.path, prepareCurrentSession, project, selectedPersonaId],
[activeWorkspace?.path, prepareCurrentSession, project],
);
const prevProjectIdRef = useRef(session?.projectId);
@@ -323,7 +321,6 @@ export function useChatSessionController({
selectedProvider,
nextProject,
activeWorkspace?.path,
selectedPersonaId ?? undefined,
effectiveModelSelection,
).catch((error) => {
console.error("Failed to update ACP session working directory:", error);
@@ -333,7 +330,6 @@ export function useChatSessionController({
activeWorkspace?.path,
effectiveModelSelection,
prepareCurrentSession,
selectedPersonaId,
selectedProvider,
sessionId,
],
@@ -423,12 +419,11 @@ export function useChatSessionController({
{
onMessageAccepted: sessionId ? onMessageAccepted : undefined,
ensurePrepared: selectedProvider
? (personaId?: string) =>
? () =>
prepareCurrentSession(
selectedProvider,
project,
activeWorkspace?.path,
personaId,
)
: undefined,
},
@@ -719,7 +714,6 @@ export function useChatSessionController({
nextProviderId,
nextProject,
activeWorkspace?.path,
nextPersonaId,
pendingModelSelection,
);
if (cancelled) {
@@ -31,7 +31,6 @@ function resetStore() {
function makeSession(overrides: Partial<ChatSession> = {}): ChatSession {
return {
id: "session-1",
acpSessionId: "session-1",
title: "Test Session",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
@@ -42,7 +41,9 @@ function makeSession(overrides: Partial<ChatSession> = {}): ChatSession {
function seedSession(overrides: Partial<ChatSession> = {}): ChatSession {
const session = makeSession(overrides);
useChatSessionStore.getState().addSession(session);
useChatSessionStore.setState((state) => ({
sessions: [session, ...state.sessions],
}));
return session;
}
@@ -59,6 +60,7 @@ describe("chatSessionStore", () => {
const session = await useChatSessionStore.getState().createSession({
title: "New Chat",
providerId: "openai",
projectId: "project-1",
personaId: "persona-1",
modelId: "gpt-4.1",
modelName: "GPT-4.1",
@@ -69,14 +71,15 @@ describe("chatSessionStore", () => {
"openai",
"/tmp/project",
{
projectId: "project-1",
personaId: "persona-1",
modelId: "gpt-4.1",
},
);
expect(session).toMatchObject({
id: "acp-1",
acpSessionId: "acp-1",
title: "New Chat",
projectId: "project-1",
providerId: "openai",
personaId: "persona-1",
modelId: "gpt-4.1",
@@ -312,35 +315,4 @@ describe("chatSessionStore", () => {
expect(useChatSessionStore.getState().activeSessionId).toBeNull();
});
});
describe("addSession", () => {
it("prepends a new session to the list", () => {
const { addSession } = useChatSessionStore.getState();
addSession(
makeSession({
id: "imported-1",
title: "Imported Session",
messageCount: 5,
}),
);
const sessions = useChatSessionStore.getState().sessions;
expect(sessions[0].id).toBe("imported-1");
expect(sessions[0].title).toBe("Imported Session");
expect(sessions[0].messageCount).toBe(5);
});
it("does not create a duplicate if session ID already exists", () => {
const { addSession } = useChatSessionStore.getState();
addSession(makeSession({ id: "dup-1", title: "First", messageCount: 1 }));
addSession(
makeSession({ id: "dup-1", title: "Second", messageCount: 2 }),
);
const sessions = useChatSessionStore.getState().sessions;
const matches = sessions.filter((session) => session.id === "dup-1");
expect(matches).toHaveLength(1);
expect(matches[0].title).toBe("Second");
});
});
});
@@ -18,7 +18,6 @@ import {
export interface ChatSession {
id: string;
acpSessionId?: string;
title: string;
projectId?: string | null;
providerId?: string;
@@ -100,7 +99,6 @@ function acpSessionToChatSession(session: AcpSessionInfo): ChatSession {
const now = new Date().toISOString();
return {
id: session.sessionId,
acpSessionId: session.sessionId,
title: normalizeAcpTitle(session.title) ?? "Untitled",
projectId: session.projectId ?? undefined,
providerId: session.providerId ?? undefined,
@@ -124,7 +122,6 @@ function sortByUpdatedAtDesc(sessions: ChatSession[]): ChatSession[] {
export function sessionToChatSession(session: Session): ChatSession {
return {
id: session.id,
acpSessionId: session.id,
title: session.title,
projectId: session.projectId,
providerId: session.providerId,
@@ -161,7 +158,6 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
});
const chatSession: ChatSession = {
id: sessionId,
acpSessionId: sessionId,
title: opts.title ?? DEFAULT_CHAT_TITLE,
projectId: opts.projectId,
providerId,
@@ -213,24 +209,23 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
}));
const updatedSession = get().sessions.find((session) => session.id === id);
const acpSessionId = updatedSession?.acpSessionId;
// Persist title rename to backend
if (
"title" in patch &&
"userSetName" in patch &&
patch.userSetName &&
acpSessionId &&
updatedSession &&
patch.title
) {
acpRenameSession(acpSessionId, patch.title).catch((err: unknown) =>
acpRenameSession(updatedSession.id, patch.title).catch((err: unknown) =>
console.error("Failed to rename session in backend:", err),
);
}
// Persist projectId change to backend
if ("projectId" in patch && acpSessionId) {
updateSessionProject(acpSessionId, patch.projectId ?? null).catch(
if ("projectId" in patch && updatedSession) {
updateSessionProject(updatedSession.id, patch.projectId ?? null).catch(
(err: unknown) =>
console.error("Failed to update session project in backend:", err),
);
@@ -238,20 +233,16 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
},
addSession: (session) => {
const normalizedSession = {
...session,
acpSessionId: session.acpSessionId ?? session.id,
};
set((state) => {
const existing = state.sessions.findIndex(
(candidate) => candidate.id === normalizedSession.id,
(candidate) => candidate.id === session.id,
);
if (existing >= 0) {
const updated = [...state.sessions];
updated[existing] = { ...updated[existing], ...normalizedSession };
updated[existing] = { ...updated[existing], ...session };
return { sessions: updated };
}
return { sessions: [normalizedSession, ...state.sessions] };
return { sessions: [session, ...state.sessions] };
});
},
@@ -266,8 +257,8 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
state.activeSessionId === id ? null : state.activeSessionId,
}));
const session = get().sessions.find((candidate) => candidate.id === id);
if (session?.acpSessionId) {
acpArchiveSession(session.acpSessionId).catch((err: unknown) =>
if (session) {
acpArchiveSession(session.id).catch((err: unknown) =>
console.error("Failed to archive session in backend:", err),
);
}
@@ -280,8 +271,8 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
),
}));
const session = get().sessions.find((candidate) => candidate.id === id);
if (session?.acpSessionId) {
acpUnarchiveSession(session.acpSessionId).catch((err: unknown) =>
if (session) {
acpUnarchiveSession(session.id).catch((err: unknown) =>
console.error("Failed to unarchive session in backend:", err),
);
}
@@ -327,13 +327,11 @@ export function McpAppView({
name: string;
arguments?: Record<string, unknown>;
}) => {
const acpSessionId = payload.gooseSessionId ?? payload.sessionId;
setActiveToolInput(args ?? {});
const client = await getClient();
const response = (await client.extMethod("_goose/tool/call", {
sessionId: acpSessionId,
sessionId: payload.sessionId,
name: `${payload.tool.extensionName}__${name}`,
arguments: args ?? {},
})) as GooseToolCallResponse;
@@ -348,22 +346,21 @@ export function McpAppView({
return toolResult;
},
[payload.gooseSessionId, payload.sessionId, payload.tool.extensionName],
[payload.sessionId, payload.tool.extensionName],
);
const handleReadResource = useCallback(
async ({ uri }: { uri: string }) => {
const acpSessionId = payload.gooseSessionId ?? payload.sessionId;
const client = await getClient();
const response = await client.goose.GooseResourceRead({
sessionId: acpSessionId,
sessionId: payload.sessionId,
uri,
extensionName: payload.tool.extensionName,
});
return (response.result ?? { contents: [] }) as ReadResourceResult;
},
[payload.gooseSessionId, payload.sessionId, payload.tool.extensionName],
[payload.sessionId, payload.tool.extensionName],
);
const handleSizeChanged = useCallback(
@@ -71,7 +71,6 @@ function createPayload({
} = {}): McpAppPayload {
return {
sessionId: "local-session",
gooseSessionId: null,
toolCallId: "tool-1",
toolCallTitle: "inspect messaging",
source: "toolCallUpdateMeta",
@@ -67,8 +67,7 @@ describe("MessageBubble MCP app rendering", () => {
type: "mcpApp",
id: "tool-1",
payload: {
sessionId: "local-session",
gooseSessionId: "goose-session",
sessionId: "acp-session",
toolCallId: "tool-1",
toolCallTitle: "weather: open app",
source: "toolCallUpdateMeta",
@@ -5,7 +5,6 @@ import type { McpAppPayload } from "@/shared/types/messages";
function createPayload(csp: unknown): McpAppPayload {
return {
sessionId: "session-1",
gooseSessionId: null,
toolCallId: "tool-1",
toolCallTitle: "inspect app",
source: "toolCallUpdateMeta",
@@ -20,8 +20,7 @@ function createDeferredPromise<T>() {
const sessions: ChatSession[] = [
{
id: "session-1",
acpSessionId: "acp-1",
id: "acp-1",
title: "Needle notes",
createdAt: "2026-04-10T12:00:00Z",
updatedAt: "2026-04-10T12:00:00Z",
@@ -72,10 +72,8 @@ export function useSessionSearch({
setError(null);
setResults(metadataResults);
const acpSessionIds = sessions.map(
(session) => session.acpSessionId ?? session.id,
);
if (trimmed.length < 2 || acpSessionIds.length === 0) {
const sessionIds = sessions.map((session) => session.id);
if (trimmed.length < 2 || sessionIds.length === 0) {
setIsSearching(false);
return;
}
@@ -83,7 +81,7 @@ export function useSessionSearch({
setIsSearching(true);
try {
const messageResults = await acpSearchSessions(trimmed, acpSessionIds);
const messageResults = await acpSearchSessions(trimmed, sessionIds);
if (requestIdRef.current !== requestId) {
return;
}
@@ -23,8 +23,7 @@ describe("buildSessionSearchResults", () => {
it("merges metadata and message matches, preferring message details", () => {
const sessions = [
makeSession({
id: "session-1",
acpSessionId: "acp-1",
id: "acp-1",
title: "Needle session",
updatedAt: "2026-04-10T12:00:00Z",
}),
@@ -59,7 +58,7 @@ describe("buildSessionSearchResults", () => {
messageId: "message-1",
matchCount: 2,
});
expect(results[0].session.id).toBe("session-1");
expect(results[0].session.id).toBe("acp-1");
});
it("includes metadata-only matches and sorts by updatedAt descending", () => {
@@ -40,15 +40,13 @@ export function buildSessionSearchResults(
return sortByUpdatedAtDesc(sessions)
.filter((session) => {
const acpSessionId = session.acpSessionId ?? session.id;
return (
metadataMatchIds.has(session.id) ||
messageMatchesBySessionId.has(acpSessionId)
messageMatchesBySessionId.has(session.id)
);
})
.map((session) => {
const acpSessionId = session.acpSessionId ?? session.id;
const messageMatch = messageMatchesBySessionId.get(acpSessionId);
const messageMatch = messageMatchesBySessionId.get(session.id);
if (!messageMatch) {
return {
session,
+84 -14
View File
@@ -1,13 +1,18 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockLoadSession = vi.fn();
const mockNewSession = vi.fn();
const mockSetProvider = vi.fn();
const mockSetModel = vi.fn();
vi.mock("../acpApi", () => ({
listProviders: vi.fn(),
prompt: vi.fn(),
setModel: vi.fn(),
setModel: (...args: unknown[]) => mockSetModel(...args),
setProvider: (...args: unknown[]) => mockSetProvider(...args),
listSessions: vi.fn(),
loadSession: (...args: unknown[]) => mockLoadSession(...args),
newSession: (...args: unknown[]) => mockNewSession(...args),
exportSession: vi.fn(),
importSession: vi.fn(),
forkSession: vi.fn(),
@@ -29,29 +34,94 @@ describe("acpLoadSession", () => {
vi.resetModules();
});
it("restores the prior session mapping when replay loading fails", async () => {
it("restores the prior prepared session registration when replay loading fails", async () => {
mockLoadSession.mockRejectedValueOnce(new Error("load failed"));
const sessionTracker = await import("../acpSessionTracker");
const sessionRegistry = await import("../acpSessionRegistry");
const { acpLoadSession } = await import("../acp");
sessionTracker.registerSession(
"local-session",
"goose-session-1",
sessionRegistry.registerPreparedSession(
"acp-session-1",
"goose",
"/tmp/original",
);
await expect(
acpLoadSession("local-session", "goose-session-2", "/tmp/replay"),
acpLoadSession("acp-session-1", "/tmp/replay"),
).rejects.toThrow("load failed");
expect(sessionTracker.getGooseSessionId("local-session")).toBe(
"goose-session-1",
);
expect(sessionTracker.getLocalSessionId("goose-session-1")).toBe(
"local-session",
);
expect(sessionTracker.getLocalSessionId("goose-session-2")).toBeNull();
expect(sessionRegistry.isSessionPrepared("acp-session-1")).toBe(true);
});
});
describe("acpCreateSession", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it("uses the ACP session id as the UI session id", async () => {
mockNewSession.mockResolvedValue({ sessionId: "acp-session-1" });
const sessionRegistry = await import("../acpSessionRegistry");
const { acpCreateSession } = await import("../acp");
await expect(
acpCreateSession("openai", "/tmp/project", {
projectId: "project-1",
personaId: "persona-1",
modelId: "gpt-4.1",
}),
).resolves.toEqual({ sessionId: "acp-session-1" });
expect(mockNewSession).toHaveBeenCalledWith(
"/tmp/project",
"openai",
"project-1",
"persona-1",
);
expect(mockLoadSession).not.toHaveBeenCalled();
expect(mockSetProvider).toHaveBeenCalledWith("acp-session-1", "openai");
expect(mockSetModel).toHaveBeenCalledWith("acp-session-1", "gpt-4.1");
expect(sessionRegistry.isSessionPrepared("acp-session-1")).toBe(true);
});
});
describe("acpPrepareSession", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it("loads the existing ACP session instead of creating a replacement", async () => {
mockLoadSession.mockResolvedValue(undefined);
const sessionRegistry = await import("../acpSessionRegistry");
const { acpPrepareSession } = await import("../acp");
await expect(
acpPrepareSession("acp-session-1", "openai", "/tmp/project"),
).resolves.toBeUndefined();
expect(mockLoadSession).toHaveBeenCalledWith(
"acp-session-1",
"/tmp/project",
);
expect(mockNewSession).not.toHaveBeenCalled();
expect(mockSetProvider).toHaveBeenCalledWith("acp-session-1", "openai");
expect(sessionRegistry.isSessionPrepared("acp-session-1")).toBe(true);
});
it("surfaces load failures instead of creating a new ACP session", async () => {
mockLoadSession.mockRejectedValueOnce(new Error("missing session"));
const { acpPrepareSession } = await import("../acp");
await expect(
acpPrepareSession("acp-session-1", "openai", "/tmp/project"),
).rejects.toThrow("missing session");
expect(mockNewSession).not.toHaveBeenCalled();
expect(mockSetProvider).not.toHaveBeenCalled();
});
});
@@ -11,12 +11,11 @@ import {
handleSessionNotification,
setActiveMessageId,
} from "../acpNotificationHandler";
import { registerSession } from "../acpSessionTracker";
import { registerPreparedSession } from "../acpSessionRegistry";
function createMcpAppPayload(): McpAppPayload {
return {
sessionId: "local-session",
gooseSessionId: "goose-session",
sessionId: "acp-session",
toolCallId: "tool-1",
toolCallTitle: "mcp_app_bench__inspect_host_info",
source: "toolCallUpdateMeta",
@@ -34,8 +33,7 @@ function createMcpAppPayload(): McpAppPayload {
describe("acpNotificationHandler", () => {
beforeEach(() => {
clearMessageTracking();
clearReplayBuffer("local-session");
clearReplayBuffer("goose-session");
clearReplayBuffer("acp-session");
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
@@ -49,16 +47,11 @@ describe("acpNotificationHandler", () => {
});
it("keeps tool calls that arrive before the first text chunk on the pending assistant message", async () => {
registerSession(
"local-session",
"goose-session",
"goose",
"/Users/aharvard",
);
setActiveMessageId("goose-session", "assistant-1");
registerPreparedSession("acp-session", "goose", "/Users/aharvard");
setActiveMessageId("acp-session", "assistant-1");
await handleSessionNotification({
sessionId: "goose-session",
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
@@ -67,7 +60,7 @@ describe("acpNotificationHandler", () => {
} as never);
await handleSessionNotification({
sessionId: "goose-session",
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
@@ -94,7 +87,7 @@ describe("acpNotificationHandler", () => {
} as never);
await handleSessionNotification({
sessionId: "goose-session",
sessionId: "acp-session",
update: {
sessionUpdate: "agent_message_chunk",
content: {
@@ -106,14 +99,13 @@ describe("acpNotificationHandler", () => {
await waitFor(() => {
const message =
useChatStore.getState().messagesBySession["local-session"]?.[0];
useChatStore.getState().messagesBySession["acp-session"]?.[0];
expect(message?.content.some((block) => block.type === "mcpApp")).toBe(
true,
);
});
const [message] =
useChatStore.getState().messagesBySession["local-session"];
const [message] = useChatStore.getState().messagesBySession["acp-session"];
expect(message.id).toBe("assistant-1");
expect(message.content.map((block) => block.type)).toEqual([
"toolRequest",
@@ -146,17 +138,17 @@ describe("acpNotificationHandler", () => {
text: "The Host Info inspector is now open.",
});
expect(
useChatStore.getState().getSessionRuntime("local-session")
useChatStore.getState().getSessionRuntime("acp-session")
.streamingMessageId,
).toBe("assistant-1");
});
it("preserves ACP tool kind and locations on tool requests", async () => {
registerSession("local-session", "goose-session", "goose", "/Users/test");
setActiveMessageId("goose-session", "assistant-1");
registerPreparedSession("acp-session", "goose", "/Users/test");
setActiveMessageId("acp-session", "assistant-1");
await handleSessionNotification({
sessionId: "goose-session",
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
@@ -168,7 +160,7 @@ describe("acpNotificationHandler", () => {
} as never);
await handleSessionNotification({
sessionId: "goose-session",
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
@@ -177,8 +169,7 @@ describe("acpNotificationHandler", () => {
},
} as never);
const [message] =
useChatStore.getState().messagesBySession["local-session"];
const [message] = useChatStore.getState().messagesBySession["acp-session"];
expect(message.content[0]).toMatchObject({
type: "toolRequest",
id: "tool-1",
@@ -190,16 +181,15 @@ describe("acpNotificationHandler", () => {
});
it("preserves structured tool output when ACP provides rawOutput", async () => {
registerSession(
"local-session",
"goose-session",
registerPreparedSession(
"acp-session",
"goose",
"/Users/aharvard/.goose/artifacts",
);
setActiveMessageId("goose-session", "assistant-1");
setActiveMessageId("acp-session", "assistant-1");
await handleSessionNotification({
sessionId: "goose-session",
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
@@ -208,7 +198,7 @@ describe("acpNotificationHandler", () => {
} as never);
await handleSessionNotification({
sessionId: "goose-session",
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
@@ -229,8 +219,7 @@ describe("acpNotificationHandler", () => {
},
} as never);
const [message] =
useChatStore.getState().messagesBySession["local-session"];
const [message] = useChatStore.getState().messagesBySession["acp-session"];
expect(message.content[1]).toMatchObject({
type: "toolResponse",
id: "tool-1",
@@ -244,7 +233,7 @@ describe("acpNotificationHandler", () => {
});
it("replay keeps tool and MCP app content on an assistant message when tool events arrive before text", async () => {
const replaySessionId = "replay-goose-session";
const replaySessionId = "replay-acp-session";
useChatStore.setState({
loadingSessionIds: new Set<string>([replaySessionId]),
});
@@ -339,7 +328,6 @@ describe("acpNotificationHandler", () => {
payload: {
...createMcpAppPayload(),
sessionId: replaySessionId,
gooseSessionId: replaySessionId,
},
});
});
@@ -442,8 +430,8 @@ describe("acpNotificationHandler", () => {
});
});
it("replay preserves gooseSessionId in MCP app payloads before tracker registration", async () => {
const replaySessionId = "replay-goose-session-2";
it("replay attaches MCP app payloads to tool-only assistant messages", async () => {
const replaySessionId = "replay-acp-session-2";
const replayCreated = 1_700_000_240;
useChatStore.setState({
loadingSessionIds: new Set<string>([replaySessionId]),
@@ -496,7 +484,7 @@ describe("acpNotificationHandler", () => {
expect(mcpAppBlock).toMatchObject({
type: "mcpApp",
payload: expect.objectContaining({
gooseSessionId: replaySessionId,
sessionId: replaySessionId,
}),
});
});
@@ -28,7 +28,6 @@ describe("ACP session info updates", () => {
it("applies generated session info updates to non-user-named sessions", async () => {
useChatSessionStore.getState().addSession({
id: "goose-session-title",
acpSessionId: "goose-session-title",
title: "New Chat",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -62,7 +61,6 @@ describe("ACP session info updates", () => {
it("ignores generated titles for user-named sessions", async () => {
useChatSessionStore.getState().addSession({
id: "goose-session-user-title",
acpSessionId: "goose-session-user-title",
title: "My Custom Title",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -9,13 +9,13 @@ import {
handleSessionNotification,
setActiveMessageId,
} from "../acpNotificationHandler";
import { registerSession } from "../acpSessionTracker";
import { registerPreparedSession } from "../acpSessionRegistry";
describe("ACP tool call status handling", () => {
beforeEach(() => {
clearMessageTracking();
clearReplayBuffer("replay-failed-tool-session");
clearReplayBuffer("goose-session");
clearReplayBuffer("acp-session");
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
@@ -76,16 +76,15 @@ describe("ACP tool call status handling", () => {
});
it("marks failed live tool updates as errors", async () => {
registerSession(
"local-session",
"goose-session",
registerPreparedSession(
"acp-session",
"goose",
"/Users/aharvard/.goose/artifacts",
);
setActiveMessageId("goose-session", "assistant-1");
setActiveMessageId("acp-session", "assistant-1");
await handleSessionNotification({
sessionId: "goose-session",
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
@@ -94,7 +93,7 @@ describe("ACP tool call status handling", () => {
} as never);
await handleSessionNotification({
sessionId: "goose-session",
sessionId: "acp-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
@@ -111,8 +110,7 @@ describe("ACP tool call status handling", () => {
},
} as never);
const [message] =
useChatStore.getState().messagesBySession["local-session"];
const [message] = useChatStore.getState().messagesBySession["acp-session"];
expect(message.content[0]).toMatchObject({
type: "toolRequest",
id: "tool-1",
+22 -47
View File
@@ -1,7 +1,7 @@
import type { ContentBlock } from "@agentclientprotocol/sdk";
import * as directAcp from "./acpApi";
import type { AcpSessionInfo } from "./acpApi";
import * as sessionTracker from "./acpSessionTracker";
import * as sessionRegistry from "./acpSessionRegistry";
import {
getCatalogEntry,
resolveAgentProviderCatalogId,
@@ -27,12 +27,9 @@ export interface AcpSendMessageOptions {
images?: [string, string][];
}
export interface AcpPrepareSessionOptions {
export interface AcpCreateSessionOptions {
personaId?: string;
projectId?: string;
}
export interface AcpCreateSessionOptions extends AcpPrepareSessionOptions {
modelId?: string | null;
}
@@ -85,8 +82,7 @@ export async function acpSendMessage(
const sid = sessionId.slice(0, 8);
const tStart = performance.now();
const gooseSessionId = sessionTracker.getGooseSessionId(sessionId, personaId);
if (!gooseSessionId) {
if (!sessionRegistry.isSessionPrepared(sessionId)) {
throw new Error("Session not prepared. Call acpPrepareSession first.");
}
@@ -113,7 +109,7 @@ export async function acpSendMessage(
}
const messageId = crypto.randomUUID();
setActiveMessageId(gooseSessionId, messageId);
setActiveMessageId(sessionId, messageId);
perfLog(
`[perf:send] ${sid} acpSendMessage → prompt(len=${prompt.length}, imgs=${images?.length ?? 0})`,
@@ -123,7 +119,7 @@ export async function acpSendMessage(
if (personaId) meta.personaId = personaId;
try {
await directAcp.prompt(
gooseSessionId,
sessionId,
content,
Object.keys(meta).length > 0 ? meta : undefined,
);
@@ -132,7 +128,7 @@ export async function acpSendMessage(
`[perf:send] ${sid} prompt() resolved in ${(tDone - tPrompt).toFixed(1)}ms (total acpSendMessage ${(tDone - tStart).toFixed(1)}ms)`,
);
} finally {
clearActiveMessageId(gooseSessionId);
clearActiveMessageId(sessionId);
}
}
@@ -141,24 +137,16 @@ export async function acpPrepareSession(
sessionId: string,
providerId: string,
workingDir: string,
options: AcpPrepareSessionOptions = {},
): Promise<string> {
): Promise<void> {
const sid = sessionId.slice(0, 8);
const t0 = performance.now();
perfLog(
`[perf:prepare] ${sid} acpPrepareSession start (provider=${providerId})`,
);
const gooseSessionId = await sessionTracker.prepareSession(
sessionId,
providerId,
workingDir,
options.personaId,
options.projectId,
);
await sessionRegistry.prepareSession(sessionId, providerId, workingDir);
perfLog(
`[perf:prepare] ${sid} acpPrepareSession done in ${(performance.now() - t0).toFixed(1)}ms`,
);
return gooseSessionId;
}
export async function acpCreateSession(
@@ -166,31 +154,26 @@ export async function acpCreateSession(
workingDir: string,
options: AcpCreateSessionOptions = {},
): Promise<{ sessionId: string }> {
const localSessionId = crypto.randomUUID();
const gooseSessionId = await acpPrepareSession(
localSessionId,
providerId,
const response = await directAcp.newSession(
workingDir,
options,
);
sessionTracker.registerSession(
gooseSessionId,
gooseSessionId,
providerId,
workingDir,
options.projectId,
options.personaId,
);
const sessionId = response.sessionId;
await directAcp.setProvider(sessionId, providerId);
sessionRegistry.registerPreparedSession(sessionId, providerId, workingDir);
if (options.modelId) {
await directAcp.setModel(gooseSessionId, options.modelId);
await directAcp.setModel(sessionId, options.modelId);
}
return { sessionId: gooseSessionId };
return { sessionId };
}
export async function acpSetModel(
sessionId: string,
modelId: string,
): Promise<void> {
const gooseSessionId = sessionTracker.getGooseSessionId(sessionId);
return directAcp.setModel(gooseSessionId ?? sessionId, modelId);
return directAcp.setModel(sessionId, modelId);
}
export type { AcpSessionInfo };
@@ -223,21 +206,19 @@ export async function acpSearchSessions(
*/
export async function acpLoadSession(
sessionId: string,
gooseSessionId: string,
workingDir?: string,
): Promise<void> {
const effectiveWorkingDir = workingDir ?? "~";
const sid = sessionId.slice(0, 8);
const t0 = performance.now();
const rollbackSessionRegistration = sessionTracker.registerSession(
const rollbackSessionRegistration = sessionRegistry.registerPreparedSession(
sessionId,
gooseSessionId,
"goose",
effectiveWorkingDir,
);
try {
perfLog(`[perf:load] ${sid} acpLoadSession → client.loadSession`);
await directAcp.loadSession(gooseSessionId, effectiveWorkingDir);
await directAcp.loadSession(sessionId, effectiveWorkingDir);
perfLog(
`[perf:load] ${sid} client.loadSession resolved in ${(performance.now() - t0).toFixed(1)}ms`,
);
@@ -261,17 +242,11 @@ export async function acpImportSession(json: string): Promise<AcpSessionInfo> {
export async function acpDuplicateSession(
sessionId: string,
): Promise<AcpSessionInfo> {
const gooseSessionId =
sessionTracker.getGooseSessionId(sessionId) ?? sessionId;
return directAcp.forkSession(gooseSessionId);
return directAcp.forkSession(sessionId);
}
/** Cancel an in-progress ACP session so the backend stops streaming. */
export async function acpCancelSession(
sessionId: string,
personaId?: string,
): Promise<boolean> {
const gooseSessionId = sessionTracker.getGooseSessionId(sessionId, personaId);
await directAcp.cancelSession(gooseSessionId ?? sessionId);
export async function acpCancelSession(sessionId: string): Promise<boolean> {
await directAcp.cancelSession(sessionId);
return true;
}
@@ -1,11 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { SessionNotification } from "@agentclientprotocol/sdk";
import { useChatStore } from "@/features/chat/stores/chatStore";
import {
clearReplayBuffer,
getAndDeleteReplayBuffer,
} from "@/features/chat/hooks/replayBuffer";
import { registerSession } from "./acpSessionTracker";
import { clearReplayBuffer } from "@/features/chat/hooks/replayBuffer";
import {
clearMessageTracking,
handleSessionNotification,
@@ -14,8 +10,8 @@ import {
describe("acpNotificationHandler", () => {
beforeEach(() => {
clearMessageTracking();
clearReplayBuffer("draft-session-1");
clearReplayBuffer("draft-session-2");
clearReplayBuffer("acp-session-1");
clearReplayBuffer("acp-session-2");
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
@@ -28,9 +24,9 @@ describe("acpNotificationHandler", () => {
});
});
it("buffers usage updates until the local session mapping is registered", async () => {
it("applies usage updates to the ACP session id", async () => {
const notification = {
sessionId: "goose-session-1",
sessionId: "acp-session-1",
update: {
sessionUpdate: "usage_update",
used: 512,
@@ -40,42 +36,9 @@ describe("acpNotificationHandler", () => {
await handleSessionNotification(notification);
expect(
useChatStore.getState().sessionStateById["draft-session-1"],
).toBeUndefined();
expect(
useChatStore.getState().sessionStateById["goose-session-1"],
).toBeUndefined();
registerSession("draft-session-1", "goose-session-1", "goose", "/tmp");
const runtime = useChatStore
.getState()
.getSessionRuntime("draft-session-1");
const runtime = useChatStore.getState().getSessionRuntime("acp-session-1");
expect(runtime.tokenState.accumulatedTotal).toBe(512);
expect(runtime.tokenState.contextLimit).toBe(8192);
expect(runtime.hasUsageSnapshot).toBe(true);
});
it("does not buffer non-usage updates before the local session mapping exists", async () => {
const notification = {
sessionId: "goose-session-2",
update: {
sessionUpdate: "agent_message_chunk",
messageId: "message-1",
content: {
type: "text",
text: "hello from replay",
},
},
} as SessionNotification;
await handleSessionNotification(notification);
registerSession("draft-session-2", "goose-session-2", "goose", "/tmp");
expect(getAndDeleteReplayBuffer("draft-session-2")).toBeUndefined();
expect(
useChatStore.getState().messagesBySession["draft-session-2"],
).toBeUndefined();
});
});
@@ -31,14 +31,10 @@ import {
} from "./acpReplayAssistant";
import { getReplayCreated, getReplayMessageId } from "./acpReplayMetadata";
import { handleSessionInfoUpdate } from "./acpSessionInfoUpdate";
import {
getLocalSessionId,
subscribeToSessionRegistration,
} from "./acpSessionTracker";
import { getToolCallIdentity } from "./acpToolCallIdentity";
import { perfLog } from "@/shared/lib/perfLog";
// Pre-set message ID for the next live stream per goose session
// Pre-set message ID for the next live stream per session.
const presetMessageIds = new Map<string, string>();
// Per-session perf counters for replay/live streaming.
@@ -54,10 +50,6 @@ interface LivePerf {
chunkCount: number;
}
const livePerf = new Map<string, LivePerf>();
const pendingUsageUpdates = new Map<
string,
{ accumulatedTotal: number; contextLimit: number }
>();
const toolCallStatusFromUpdate = (status: string): ToolCallStatus =>
status === "failed" ? "error" : "completed";
@@ -108,33 +100,20 @@ function toolCallUpdatePatch(
};
}
subscribeToSessionRegistration((localSessionId, gooseSessionId) => {
const pendingUsage = pendingUsageUpdates.get(gooseSessionId);
if (!pendingUsage) {
return;
}
useChatStore.getState().updateTokenState(localSessionId, pendingUsage);
pendingUsageUpdates.delete(gooseSessionId);
});
export function setActiveMessageId(
gooseSessionId: string,
messageId: string,
): void {
presetMessageIds.set(gooseSessionId, messageId);
livePerf.set(gooseSessionId, {
export function setActiveMessageId(sessionId: string, messageId: string): void {
presetMessageIds.set(sessionId, messageId);
livePerf.set(sessionId, {
sendStartedAt: performance.now(),
firstChunkAt: null,
chunkCount: 0,
});
}
export function clearActiveMessageId(gooseSessionId: string): void {
presetMessageIds.delete(gooseSessionId);
const perf = livePerf.get(gooseSessionId);
export function clearActiveMessageId(sessionId: string): void {
presetMessageIds.delete(sessionId);
const perf = livePerf.get(sessionId);
if (perf) {
const sid = gooseSessionId.slice(0, 8);
const sid = sessionId.slice(0, 8);
const total = performance.now() - perf.sendStartedAt;
const ttft =
perf.firstChunkAt !== null
@@ -143,16 +122,14 @@ export function clearActiveMessageId(gooseSessionId: string): void {
perfLog(
`[perf:stream] ${sid} stream ended — ttft=${ttft}ms total=${total.toFixed(1)}ms chunks=${perf.chunkCount}`,
);
livePerf.delete(gooseSessionId);
livePerf.delete(sessionId);
}
}
export async function handleSessionNotification(
notification: SessionNotification,
): Promise<void> {
const gooseSessionId = notification.sessionId;
const localSessionId = getLocalSessionId(gooseSessionId);
const sessionId = localSessionId ?? gooseSessionId;
const sessionId = notification.sessionId;
const { update } = notification;
const isReplay = useChatStore.getState().loadingSessionIds.has(sessionId);
@@ -167,20 +144,20 @@ export async function handleSessionNotification(
}
perf.lastAt = now;
perf.count += 1;
handleReplay(sessionId, gooseSessionId, localSessionId, update);
handleReplay(sessionId, update);
} else {
const perf = livePerf.get(gooseSessionId);
const perf = livePerf.get(sessionId);
if (perf && update.sessionUpdate === "agent_message_chunk") {
perf.chunkCount += 1;
if (perf.firstChunkAt === null) {
perf.firstChunkAt = performance.now();
const sid = gooseSessionId.slice(0, 8);
const sid = sessionId.slice(0, 8);
perfLog(
`[perf:stream] ${sid} first agent_message_chunk at ttft=${(perf.firstChunkAt - perf.sendStartedAt).toFixed(1)}ms`,
);
}
}
handleLive(sessionId, gooseSessionId, localSessionId, update);
handleLive(sessionId, update);
}
}
@@ -202,12 +179,7 @@ function getChunkMessageId(update: SessionUpdate): string | null {
: null;
}
function handleReplay(
sessionId: string,
gooseSessionId: string,
localSessionId: string | null,
update: SessionUpdate,
): void {
function handleReplay(sessionId: string, update: SessionUpdate): void {
switch (update.sessionUpdate) {
case "agent_message_chunk": {
const msg = ensureReplayAssistantMessage(
@@ -331,7 +303,6 @@ function handleReplay(
update,
true,
{
gooseSessionId,
replayMessageId,
},
);
@@ -344,7 +315,7 @@ function handleReplay(
case "session_info_update":
case "config_option_update":
case "usage_update":
handleShared(sessionId, gooseSessionId, localSessionId, update);
handleShared(sessionId, update);
break;
default:
@@ -352,19 +323,13 @@ function handleReplay(
}
}
function handleLive(
sessionId: string,
gooseSessionId: string,
localSessionId: string | null,
update: SessionUpdate,
): void {
function handleLive(sessionId: string, update: SessionUpdate): void {
const store = useChatStore.getState();
switch (update.sessionUpdate) {
case "agent_message_chunk": {
const messageId = ensureLiveAssistantMessage(
sessionId,
gooseSessionId,
getChunkMessageId(update) ?? undefined,
);
@@ -376,7 +341,7 @@ function handleLive(
}
case "tool_call": {
const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId);
const messageId = ensureLiveAssistantMessage(sessionId);
const identity = getToolCallIdentity(update);
const toolRequest: ToolRequestContent = {
@@ -395,7 +360,7 @@ function handleLive(
}
case "tool_call_update": {
const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId);
const messageId = ensureLiveAssistantMessage(sessionId);
const identity = getToolCallIdentity(update);
const patch = toolCallUpdatePatch(update);
@@ -460,9 +425,6 @@ function handleLive(
toolRequest?.name ?? update.title ?? "",
update,
false,
{
gooseSessionId,
},
);
}
}
@@ -472,7 +434,7 @@ function handleLive(
case "session_info_update":
case "config_option_update":
case "usage_update":
handleShared(sessionId, gooseSessionId, localSessionId, update);
handleShared(sessionId, update);
break;
default:
@@ -480,12 +442,7 @@ function handleLive(
}
}
function handleShared(
sessionId: string,
gooseSessionId: string,
localSessionId: string | null,
update: SessionUpdate,
): void {
function handleShared(sessionId: string, update: SessionUpdate): void {
switch (update.sessionUpdate) {
case "session_info_update": {
handleSessionInfoUpdate(sessionId, update);
@@ -535,15 +492,7 @@ function handleShared(
case "usage_update": {
const usage = update as SessionUpdate & { sessionUpdate: "usage_update" };
if (!localSessionId) {
pendingUsageUpdates.set(gooseSessionId, {
accumulatedTotal: usage.used,
contextLimit: usage.size,
});
break;
}
useChatStore.getState().updateTokenState(localSessionId, {
useChatStore.getState().updateTokenState(sessionId, {
accumulatedTotal: usage.used,
contextLimit: usage.size,
});
@@ -562,7 +511,6 @@ function findStreamingMessageId(sessionId: string): string | null {
function ensureLiveAssistantMessage(
sessionId: string,
gooseSessionId: string,
preferredMessageId?: string | null,
): string {
const store = useChatStore.getState();
@@ -578,7 +526,7 @@ function ensureLiveAssistantMessage(
const messageId =
preferredMessageId ??
presetMessageIds.get(gooseSessionId) ??
presetMessageIds.get(sessionId) ??
existingStreamingMessageId ??
crypto.randomUUID();
@@ -598,14 +546,13 @@ function ensureLiveAssistantMessage(
store.setPendingAssistantProvider(sessionId, null);
store.setStreamingMessageId(sessionId, messageId);
clearActiveMessageId(gooseSessionId);
clearActiveMessageId(sessionId);
return messageId;
}
export function clearMessageTracking(): void {
presetMessageIds.clear();
pendingUsageUpdates.clear();
clearReplayAssistantTracking();
}
@@ -0,0 +1,80 @@
import * as acpApi from "./acpApi";
import { perfLog } from "@/shared/lib/perfLog";
interface PreparedSession {
providerId: string;
workingDir: string;
}
const prepared = new Map<string, PreparedSession>();
export async function prepareSession(
sessionId: string,
providerId: string,
workingDir: string,
): Promise<void> {
const sid = sessionId.slice(0, 8);
const existing = prepared.get(sessionId);
if (existing) {
const tReuse = performance.now();
let changed = false;
if (existing.workingDir !== workingDir) {
await acpApi.updateWorkingDir(sessionId, workingDir);
existing.workingDir = workingDir;
changed = true;
}
if (existing.providerId !== providerId) {
const tProv = performance.now();
await acpApi.setProvider(sessionId, providerId);
perfLog(
`[perf:prepare] ${sid} reuse setProvider(${providerId}) in ${(performance.now() - tProv).toFixed(1)}ms`,
);
existing.providerId = providerId;
changed = true;
}
perfLog(
`[perf:prepare] ${sid} reuse existing session (updates=${changed}) in ${(performance.now() - tReuse).toFixed(1)}ms`,
);
return;
}
const tLoad = performance.now();
await acpApi.loadSession(sessionId, workingDir);
perfLog(
`[perf:prepare] ${sid} registry loadSession ok in ${(performance.now() - tLoad).toFixed(1)}ms`,
);
const tProv = performance.now();
await acpApi.setProvider(sessionId, providerId);
perfLog(
`[perf:prepare] ${sid} registry setProvider(${providerId}) in ${(performance.now() - tProv).toFixed(1)}ms`,
);
const entry = { providerId, workingDir };
prepared.set(sessionId, entry);
return;
}
export function isSessionPrepared(sessionId: string): boolean {
return prepared.has(sessionId);
}
export function registerPreparedSession(
sessionId: string,
providerId: string,
workingDir: string,
): () => void {
const previousEntry = prepared.get(sessionId);
const entry = { providerId, workingDir };
prepared.set(sessionId, entry);
return () => {
prepared.delete(sessionId);
if (previousEntry) {
prepared.set(sessionId, previousEntry);
}
};
}
@@ -1,206 +0,0 @@
import * as acpApi from "./acpApi";
import { perfLog } from "@/shared/lib/perfLog";
interface PreparedSession {
gooseSessionId: string;
providerId: string;
workingDir: string;
}
type SessionRegistrationListener = (
localSessionId: string,
gooseSessionId: string,
) => void;
const prepared = new Map<string, PreparedSession>();
const gooseToLocal = new Map<string, string>();
const registrationListeners = new Set<SessionRegistrationListener>();
function restoreGooseRegistration(
gooseSessionId: string,
localSessionId: string | undefined,
): void {
if (localSessionId === undefined) {
gooseToLocal.delete(gooseSessionId);
return;
}
gooseToLocal.set(gooseSessionId, localSessionId);
}
function makeKey(sessionId: string, personaId?: string): string {
if (personaId && personaId.length > 0) {
return `${sessionId}__${personaId}`;
}
return sessionId;
}
function notifySessionRegistered(
localSessionId: string,
gooseSessionId: string,
): void {
for (const listener of registrationListeners) {
listener(localSessionId, gooseSessionId);
}
}
export function subscribeToSessionRegistration(
listener: SessionRegistrationListener,
): () => void {
registrationListeners.add(listener);
return () => registrationListeners.delete(listener);
}
export async function prepareSession(
sessionId: string,
providerId: string,
workingDir: string,
personaId?: string,
projectId?: string,
): Promise<string> {
const sid = sessionId.slice(0, 8);
const key = makeKey(sessionId, personaId);
const existing = prepared.get(key) ?? prepared.get(sessionId);
if (existing) {
const tReuse = performance.now();
let changed = false;
if (existing.workingDir !== workingDir) {
await acpApi.updateWorkingDir(existing.gooseSessionId, workingDir);
existing.workingDir = workingDir;
changed = true;
}
if (existing.providerId !== providerId) {
const tProv = performance.now();
await acpApi.setProvider(existing.gooseSessionId, providerId);
perfLog(
`[perf:prepare] ${sid} reuse setProvider(${providerId}) in ${(performance.now() - tProv).toFixed(1)}ms (goose_sid=${existing.gooseSessionId.slice(0, 8)})`,
);
existing.providerId = providerId;
changed = true;
}
perfLog(
`[perf:prepare] ${sid} reuse existing session (updates=${changed}) in ${(performance.now() - tReuse).toFixed(1)}ms`,
);
return existing.gooseSessionId;
}
let gooseSessionId: string | null = null;
const tLoad = performance.now();
try {
await acpApi.loadSession(sessionId, workingDir);
gooseSessionId = sessionId;
perfLog(
`[perf:prepare] ${sid} tracker loadSession ok in ${(performance.now() - tLoad).toFixed(1)}ms`,
);
} catch {
perfLog(
`[perf:prepare] ${sid} tracker loadSession failed in ${(performance.now() - tLoad).toFixed(1)}ms → newSession`,
);
}
if (!gooseSessionId) {
const tNew = performance.now();
const response = await acpApi.newSession(
workingDir,
providerId,
projectId,
personaId,
);
gooseSessionId = response.sessionId;
perfLog(
`[perf:prepare] ${sid} tracker newSession done in ${(performance.now() - tNew).toFixed(1)}ms (goose_sid=${gooseSessionId.slice(0, 8)})`,
);
}
const gooseSid = gooseSessionId.slice(0, 8);
const tProv = performance.now();
await acpApi.setProvider(gooseSessionId, providerId);
perfLog(
`[perf:prepare] ${sid} tracker setProvider(${providerId}) in ${(performance.now() - tProv).toFixed(1)}ms (goose_sid=${gooseSid})`,
);
const entry = { gooseSessionId, providerId, workingDir };
prepared.set(key, entry);
prepared.set(sessionId, entry);
prepared.set(gooseSessionId, entry);
gooseToLocal.set(gooseSessionId, sessionId);
notifySessionRegistered(sessionId, gooseSessionId);
return gooseSessionId;
}
export function getGooseSessionId(
sessionId: string,
personaId?: string,
): string | null {
const key = makeKey(sessionId, personaId);
return (
prepared.get(key)?.gooseSessionId ??
prepared.get(sessionId)?.gooseSessionId ??
null
);
}
export function getLocalSessionId(gooseSessionId: string): string | null {
return gooseToLocal.get(gooseSessionId) ?? null;
}
export function registerSession(
sessionId: string,
gooseSessionId: string,
providerId: string,
workingDir: string,
): () => void {
const previousEntry = prepared.get(sessionId);
const previousGooseSessionLocal = gooseToLocal.get(gooseSessionId);
const previousSessionGooseLocal = previousEntry
? gooseToLocal.get(previousEntry.gooseSessionId)
: undefined;
const entry = { gooseSessionId, providerId, workingDir };
if (
previousEntry &&
previousEntry.gooseSessionId !== gooseSessionId &&
gooseToLocal.get(previousEntry.gooseSessionId) === sessionId
) {
gooseToLocal.delete(previousEntry.gooseSessionId);
}
prepared.set(sessionId, entry);
prepared.set(gooseSessionId, entry);
gooseToLocal.set(gooseSessionId, sessionId);
notifySessionRegistered(sessionId, gooseSessionId);
return () => {
prepared.delete(sessionId);
if (previousEntry) {
prepared.set(sessionId, previousEntry);
}
restoreGooseRegistration(gooseSessionId, previousGooseSessionLocal);
if (previousEntry && previousEntry.gooseSessionId !== gooseSessionId) {
restoreGooseRegistration(
previousEntry.gooseSessionId,
previousSessionGooseLocal,
);
}
};
}
export function unregisterSession(
sessionId: string,
gooseSessionId?: string,
): void {
const entry = prepared.get(sessionId);
prepared.delete(sessionId);
const resolvedGooseSessionId = gooseSessionId ?? entry?.gooseSessionId;
if (
resolvedGooseSessionId &&
gooseToLocal.get(resolvedGooseSessionId) === sessionId
) {
gooseToLocal.delete(resolvedGooseSessionId);
}
}
@@ -66,7 +66,6 @@ export function attachMcpAppPayload(
update: SessionUpdate,
isReplay: boolean,
options?: {
gooseSessionId?: string | null;
replayMessageId?: string | null;
},
): void {
@@ -75,7 +74,6 @@ export function attachMcpAppPayload(
toolCallId,
toolCallTitle,
update,
options?.gooseSessionId,
);
if (!payload) {
return;
@@ -6,7 +6,6 @@ import type {
} from "@aaif/goose-sdk";
import type { SessionUpdate } from "@agentclientprotocol/sdk";
import type { McpAppPayload } from "@/shared/types/messages";
import { getGooseSessionId } from "./acpSessionTracker";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -29,7 +28,6 @@ export function buildMcpAppPayloadFromToolUpdate(
toolCallId: string,
toolCallTitle: string,
update: SessionUpdate,
gooseSessionIdOverride?: string | null,
): McpAppPayload | null {
const payload = extractMcpAppPayload(update);
if (!payload) {
@@ -38,8 +36,6 @@ export function buildMcpAppPayloadFromToolUpdate(
return {
sessionId,
gooseSessionId:
gooseSessionIdOverride ?? getGooseSessionId(sessionId) ?? null,
toolCallId,
toolCallTitle,
source: "toolCallUpdateMeta",
-1
View File
@@ -118,7 +118,6 @@ export interface ToolResponseContent {
export interface McpAppPayload {
sessionId: string;
gooseSessionId: string | null;
toolCallId: string;
toolCallTitle: string;
source: "toolCallUpdateMeta";