fix(chat): unsubscribe when leaving running subagents

This commit is contained in:
murashit 2026-07-17 19:46:27 +09:00
parent 4b0218169c
commit 716d6a2db5
13 changed files with 448 additions and 12 deletions

View file

@ -31,6 +31,7 @@ import type {
} from "../../application/threads/thread-loading-transport";
import type { ThreadMutationTransport, ThreadRollbackSnapshot } from "../../application/threads/thread-mutation-transport";
import type { ThreadStartTransport } from "../../application/threads/thread-start-transport";
import type { ThreadSubscriptionTransport } from "../../application/threads/thread-subscription-transport";
import type { ChatTurnTransport } from "../../application/turns/turn-transport";
import { threadStreamItemsFromTurns } from "../mappers/thread-stream/turn-items";
@ -65,6 +66,7 @@ export interface ChatConnectedSessionTransports {
readonly threadResume: ThreadResumeTransport;
readonly threadMutation: ThreadMutationTransport;
readonly threadEphemeral: EphemeralThreadTransport;
readonly threadSubscription: ThreadSubscriptionTransport;
readonly threadGoal: ThreadGoalTransport;
}
@ -83,6 +85,7 @@ export function createChatConnectedSessionTransports(host: ChatAppServerTranspor
threadResume: createChatThreadResumeTransport(host),
threadMutation: createChatThreadMutationTransport(host),
threadEphemeral: createChatEphemeralThreadTransport(host),
threadSubscription: createChatThreadSubscriptionTransport(host),
threadGoal: createChatThreadGoalTransport(host),
};
}
@ -204,6 +207,18 @@ function createChatEphemeralThreadTransport(host: ChatAppServerTransportHost): E
};
}
function createChatThreadSubscriptionTransport(host: ChatAppServerTransportHost): ThreadSubscriptionTransport {
return {
unsubscribeThread: async (threadId) => {
const result = await withCurrentChatAppServerClient(host, async (client) => {
await unsubscribeThread(client, threadId, { timeoutMs: 5_000 });
return true;
});
return result ?? false;
},
};
}
function createChatThreadGoalReadTransport(host: CurrentChatAppServerClientHost): ThreadGoalReadTransport {
return {
readThreadGoal: (threadId) => readThreadGoalFromCurrentClient(host, threadId),

View file

@ -0,0 +1,59 @@
import { activeThreadState } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import { chatTurnBusy } from "../turns/turn-state";
import type { EphemeralThreadLifecycle } from "./ephemeral-thread-lifecycle";
import type { ThreadSubscriptionTransport } from "./thread-subscription-transport";
export interface PersistentNavigationLifecycle {
prepareForPersistentNavigation(targetThreadId: string | null): Promise<PersistentNavigationPreparation | null>;
completePersistentNavigation(preparation: PersistentNavigationPreparation): Promise<void>;
}
export type PersistentNavigationPreparation =
| { readonly kind: "ready" }
| { readonly kind: "unsubscribe-after-resume"; readonly threadId: string; readonly targetThreadId: string };
interface PersistentNavigationLifecycleHost {
stateStore: ChatStateStore;
ephemeral: EphemeralThreadLifecycle;
subscriptions: ThreadSubscriptionTransport;
addSystemMessage(text: string): void;
}
export function createPersistentNavigationLifecycle(host: PersistentNavigationLifecycleHost): PersistentNavigationLifecycle {
return {
async prepareForPersistentNavigation(targetThreadId): Promise<PersistentNavigationPreparation | null> {
const state = host.stateStore.getState();
const active = activeThreadState(state);
if (!active || active.id === targetThreadId) return { kind: "ready" };
if (active.lifetime?.kind === "ephemeral") {
return (await host.ephemeral.prepareForPersistentNavigation()) ? { kind: "ready" } : null;
}
if (active.provenance?.kind !== "subagent" || !chatTurnBusy(state)) return { kind: "ready" };
if (targetThreadId !== null) {
return { kind: "unsubscribe-after-resume", threadId: active.id, targetThreadId };
}
try {
if (await host.subscriptions.unsubscribeThread(active.id)) return { kind: "ready" };
host.addSystemMessage("Could not leave the running subagent. Try again before navigating.");
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
return null;
},
async completePersistentNavigation(preparation): Promise<void> {
if (preparation.kind !== "unsubscribe-after-resume") return;
if (activeThreadState(host.stateStore.getState())?.id !== preparation.targetThreadId) return;
try {
if (!(await host.subscriptions.unsubscribeThread(preparation.threadId))) {
host.addSystemMessage("Could not unsubscribe from the previous subagent.");
}
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
},
};
}

View file

@ -2,6 +2,7 @@ import { activeThreadState } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import { chatTurnBusy } from "../turns/turn-state";
import type { ActiveThreadIdentitySync } from "./active-thread-identity-sync";
import type { PersistentNavigationLifecycle } from "./persistent-navigation-lifecycle";
import { canSwitchToThread } from "./thread-switching";
export interface ThreadNavigationActionsHost {
@ -12,7 +13,7 @@ export interface ThreadNavigationActionsHost {
resumeThread: (threadId: string) => Promise<void>;
addSystemMessage: (text: string) => void;
focusComposer: () => void;
prepareForPersistentNavigation?: () => Promise<boolean>;
navigation: PersistentNavigationLifecycle;
}
export interface ThreadNavigationActions {
@ -28,17 +29,19 @@ export function createThreadNavigationActions(host: ThreadNavigationActionsHost)
return;
}
if (host.prepareForPersistentNavigation && !(await host.prepareForPersistentNavigation())) return;
host.closeForThreadSelection();
if (await host.focusThreadInOpenView(threadId)) return;
const preparation = await host.navigation.prepareForPersistentNavigation(threadId);
if (!preparation) return;
await host.resumeThread(threadId);
await host.navigation.completePersistentNavigation(preparation);
};
return {
async startNewThread(): Promise<void> {
const state = host.stateStore.getState();
if (chatTurnBusy(state) && activeThreadState(state)?.provenance?.kind !== "subagent") return;
if (host.prepareForPersistentNavigation && !(await host.prepareForPersistentNavigation())) return;
if (!(await host.navigation.prepareForPersistentNavigation(null))) return;
host.identity.clearActiveThreadIdentity();
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
@ -48,7 +51,6 @@ export function createThreadNavigationActions(host: ThreadNavigationActionsHost)
selectThread,
async selectThreadFromToolbar(threadId) {
if (!canSwitchToThread(host.stateStore.getState(), threadId)) return;
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
await selectThread(threadId);
},

View file

@ -0,0 +1,3 @@
export interface ThreadSubscriptionTransport {
unsubscribeThread(threadId: string): Promise<boolean>;
}

View file

@ -1,6 +1,6 @@
import { activeThreadId, type ChatState } from "../state/root-reducer";
import { activeThreadId, activeThreadState, type ChatState } from "../state/root-reducer";
import { chatTurnBusy } from "../turns/turn-state";
export function canSwitchToThread(state: ChatState, threadId: string): boolean {
return !chatTurnBusy(state) || threadId === activeThreadId(state);
return !chatTurnBusy(state) || threadId === activeThreadId(state) || activeThreadState(state)?.provenance?.kind === "subagent";
}

View file

@ -13,6 +13,7 @@ import { type ActiveThreadIdentitySync, createActiveThreadIdentitySync } from ".
import { type AutoTitleCoordinator, createAutoTitleCoordinator } from "../../application/threads/auto-title-coordinator";
import { createGoalActions, createThreadGoalSyncActions } from "../../application/threads/goal-actions";
import { HistoryController } from "../../application/threads/history-controller";
import type { PersistentNavigationLifecycle } from "../../application/threads/persistent-navigation-lifecycle";
import {
activeThreadRenameTitleContext,
createThreadRenameEditorActions,
@ -100,7 +101,7 @@ interface ChatPanelThreadActionInput {
lifecycle: ChatPanelThreadLifecycleBundle;
refreshActiveThreads: () => Promise<void>;
notifyActiveThreadIdentityChanged: () => void;
prepareForPersistentNavigation: () => Promise<boolean>;
navigation: PersistentNavigationLifecycle;
}
interface ChatPanelThreadActionBundle {
@ -306,7 +307,7 @@ export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatP
focusComposer: () => {
composerController.focusComposer();
},
prepareForPersistentNavigation: input.prepareForPersistentNavigation,
navigation: input.navigation,
});
return { actions, toolbarPanelActions, navigation };
}

View file

@ -9,6 +9,10 @@ import type { ChatAction, ChatConnectionPhase } from "../application/state/root-
import type { ChatStateStore } from "../application/state/store";
import type { ActiveThreadIdentitySync } from "../application/threads/active-thread-identity-sync";
import { createEphemeralThreadLifecycle, type EphemeralThreadLifecycle } from "../application/threads/ephemeral-thread-lifecycle";
import {
createPersistentNavigationLifecycle,
type PersistentNavigationLifecycle,
} from "../application/threads/persistent-navigation-lifecycle";
import type { RestorationController } from "../application/threads/restoration-controller";
import type { ResumeActions } from "../application/threads/resume-actions";
import type { ChatResumeWorkTracker } from "../application/threads/resume-work";
@ -37,6 +41,7 @@ interface ChatPanelSessionRuntimeParts {
restoration: RestorationController;
identity: ActiveThreadIdentitySync;
ephemeral: EphemeralThreadLifecycle;
navigation: PersistentNavigationLifecycle;
};
composer: {
controller: ChatComposerController;
@ -216,6 +221,12 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
notifyActiveThreadIdentityChanged,
interruptTurn: (threadId, turnId) => appServer.turn.interruptTurn(threadId, turnId),
});
const navigation = createPersistentNavigationLifecycle({
stateStore,
ephemeral,
subscriptions: appServer.threadSubscription,
addSystemMessage: status.addSystemMessage,
});
const threadActions = createThreadActionBundle(host, {
appServer,
status,
@ -224,7 +235,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
lifecycle: threadLifecycle,
refreshActiveThreads,
notifyActiveThreadIdentityChanged,
prepareForPersistentNavigation: () => ephemeral.prepareForPersistentNavigation(),
navigation,
});
const reconnectHost = {
stateStore,
@ -326,6 +337,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
restoration: threadLifecycle.restoration,
identity: threadLifecycle.identity,
ephemeral,
navigation,
},
composer: {
controller: composerController,

View file

@ -178,8 +178,10 @@ export class ChatPanelSession implements ChatPanelHandle {
}
async openThread(threadId: string): Promise<void> {
if (!(await this.runtime.thread.ephemeral.prepareForPersistentNavigation())) return;
const preparation = await this.runtime.thread.navigation.prepareForPersistentNavigation(threadId);
if (!preparation) return;
await this.runtime.thread.resume.resumeThread(threadId);
await this.runtime.thread.navigation.completePersistentNavigation(preparation);
this.focusComposer();
}

View file

@ -645,6 +645,39 @@ describe("ChatInboundHandler", () => {
});
describe("interactive server requests", () => {
it("keeps matching requests actionable while a subagent is active", () => {
const state = chatStateFixture({
activeThread: {
id: "thread-active",
provenance: {
kind: "subagent",
subagentKind: "thread-spawn",
parentThreadId: "parent",
sessionId: "session",
depth: 1,
agentNickname: "Scout",
agentRole: "explorer",
},
},
turn: { lifecycle: { kind: "running", turnId: "turn-active" } },
});
const handler = handlerForState(state);
handler.handleServerRequest({
id: 41,
method: "item/tool/requestUserInput",
params: {
threadId: "thread-active",
turnId: "turn-active",
itemId: "input-1",
questions: [{ id: "scope", header: "Scope", question: "What should I do?", isOther: false, isSecret: false, options: null }],
autoResolutionMs: null,
},
});
expect(handler.currentState().requests.pendingUserInputs).toHaveLength(1);
});
it("queues and resolves requestUserInput server requests", () => {
let state = chatStateFixture();
state = chatStateWith(state, {

View file

@ -325,6 +325,20 @@ describe("chat app-server transports", () => {
await expect(transport.resumeThread("thread")).resolves.toBeNull();
});
it("unsubscribes a panel from a persistent thread without interrupting its turn", async () => {
const request = vi.fn().mockResolvedValue({ status: "unsubscribed" });
const client = { request } as unknown as AppServerClient;
const transport = createTestGateway({
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
}).threadSubscription;
await expect(transport.unsubscribeThread("child")).resolves.toBe(true);
expect(request).toHaveBeenCalledWith("thread/unsubscribe", { threadId: "child" }, { timeoutMs: 5_000 });
expect(request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything());
});
it("distinguishes absent goals from unavailable goal clients", async () => {
const client = { request: vi.fn().mockResolvedValue({ goal: null }) } as unknown as AppServerClient;
const transport = createTestGateway({

View file

@ -0,0 +1,143 @@
import { describe, expect, it, vi } from "vitest";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import type { EphemeralThreadLifecycle } from "../../../../../src/features/chat/application/threads/ephemeral-thread-lifecycle";
import {
createPersistentNavigationLifecycle,
type PersistentNavigationLifecycle,
} from "../../../../../src/features/chat/application/threads/persistent-navigation-lifecycle";
import type { ThreadSubscriptionTransport } from "../../../../../src/features/chat/application/threads/thread-subscription-transport";
describe("persistent navigation lifecycle", () => {
it("unsubscribes a running persistent subagent only after the target resume becomes active", async () => {
const store = runningSubagentStore();
const subscriptions = subscriptionTransport();
const ephemeral = ephemeralLifecycle();
const lifecycle = createLifecycle({ stateStore: store, subscriptions, ephemeral });
const preparation = await lifecycle.prepareForPersistentNavigation("other");
expect(preparation).toEqual({ kind: "unsubscribe-after-resume", threadId: "child", targetThreadId: "other" });
expect(subscriptions.unsubscribeThread).not.toHaveBeenCalled();
expect(ephemeral.prepareForPersistentNavigation).not.toHaveBeenCalled();
expect(store.getState().panelThread).toMatchObject({ kind: "active", thread: { id: "child" } });
resumeInteractiveThread(store, "other");
if (!preparation) throw new Error("Expected navigation preparation.");
await lifecycle.completePersistentNavigation(preparation);
expect(subscriptions.unsubscribeThread).toHaveBeenCalledWith("child");
});
it("keeps the subagent subscribed when target resume does not replace it", async () => {
const store = runningSubagentStore();
const subscriptions = subscriptionTransport();
const lifecycle = createLifecycle({ stateStore: store, subscriptions });
const preparation = await lifecycle.prepareForPersistentNavigation("other");
if (!preparation) throw new Error("Expected navigation preparation.");
await lifecycle.completePersistentNavigation(preparation);
expect(subscriptions.unsubscribeThread).not.toHaveBeenCalled();
expect(store.getState().panelThread).toMatchObject({ kind: "active", thread: { id: "child" } });
});
it("blocks clearing a running subagent when pre-unsubscribe fails", async () => {
const store = runningSubagentStore();
const subscriptions = subscriptionTransport();
vi.mocked(subscriptions.unsubscribeThread).mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const addSystemMessage = vi.fn();
const lifecycle = createLifecycle({ stateStore: store, subscriptions, addSystemMessage });
await expect(lifecycle.prepareForPersistentNavigation(null)).resolves.toBeNull();
expect(store.getState().panelThread).toMatchObject({ kind: "active", thread: { id: "child" } });
expect(addSystemMessage).toHaveBeenCalledWith("Could not leave the running subagent. Try again before navigating.");
});
it("does not unsubscribe when the target is the already active subagent", async () => {
const subscriptions = subscriptionTransport();
const lifecycle = createLifecycle({ stateStore: runningSubagentStore(), subscriptions });
await expect(lifecycle.prepareForPersistentNavigation("child")).resolves.toEqual({ kind: "ready" });
expect(subscriptions.unsubscribeThread).not.toHaveBeenCalled();
});
});
function runningSubagentStore() {
const store = createChatStateStore();
store.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: {
id: "child",
cliVersion: "test",
provenance: {
kind: "subagent",
subagentKind: "thread-spawn",
parentThreadId: "parent",
sessionId: "session",
depth: 1,
agentNickname: "Scout",
agentRole: "explorer",
},
} as never,
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
store.dispatch({ type: "turn/started", threadId: "child", turnId: "turn" });
return store;
}
function resumeInteractiveThread(store: ReturnType<typeof createChatStateStore>, threadId: string): void {
store.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { id: threadId, cliVersion: "test", provenance: { kind: "interactive" } } as never,
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
});
}
function createLifecycle(options: {
stateStore: ReturnType<typeof createChatStateStore>;
subscriptions: ThreadSubscriptionTransport;
ephemeral?: EphemeralThreadLifecycle;
addSystemMessage?: (text: string) => void;
}): PersistentNavigationLifecycle {
return createPersistentNavigationLifecycle({
stateStore: options.stateStore,
subscriptions: options.subscriptions,
ephemeral: options.ephemeral ?? ephemeralLifecycle(),
addSystemMessage: options.addSystemMessage ?? vi.fn(),
});
}
function subscriptionTransport(result = true): ThreadSubscriptionTransport {
return { unsubscribeThread: vi.fn().mockResolvedValue(result) };
}
function ephemeralLifecycle(): EphemeralThreadLifecycle {
return {
open: vi.fn(),
prepareForPersistentNavigation: vi.fn().mockResolvedValue(true),
dispose: vi.fn().mockResolvedValue(undefined),
};
}

View file

@ -3,6 +3,10 @@ import { describe, expect, it, vi } from "vitest";
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { type ChatStateStore, createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import type { ActiveThreadIdentitySync } from "../../../../../src/features/chat/application/threads/active-thread-identity-sync";
import type {
PersistentNavigationLifecycle,
PersistentNavigationPreparation,
} from "../../../../../src/features/chat/application/threads/persistent-navigation-lifecycle";
import {
createThreadNavigationActions,
type ThreadNavigationActionsHost,
@ -52,6 +56,7 @@ function createActionsHarness(overrides: Partial<ThreadNavigationActionsHost> =
resumeThread: vi.fn().mockResolvedValue(undefined),
addSystemMessage: vi.fn(),
focusComposer: vi.fn(),
navigation: navigationMock(),
...overrides,
};
return { actions: createThreadNavigationActions(host), host, stateStore };
@ -84,16 +89,32 @@ describe("ThreadNavigationActions", () => {
});
it("starts a blank chat while a subagent turn continues", async () => {
const { actions, host, stateStore } = createActionsHarness();
const navigation = navigationMock();
const { actions, host, stateStore } = createActionsHarness({ navigation });
resumeThreadState(stateStore, "child", true);
stateStore.dispatch({ type: "turn/started", threadId: "child", turnId: "turn" });
await actions.startNewThread();
expect(navigation.prepareForPersistentNavigation).toHaveBeenCalledWith(null);
expect(host.identity.clearActiveThreadIdentity).toHaveBeenCalledOnce();
expectCallBefore(navigation.prepareForPersistentNavigation, host.identity.clearActiveThreadIdentity as ReturnType<typeof vi.fn>);
expect(host.focusComposer).toHaveBeenCalledOnce();
});
it("keeps a running subagent active when unsubscribe preparation fails", async () => {
const navigation = navigationMock(null);
const { actions, host, stateStore } = createActionsHarness({ navigation });
resumeThreadState(stateStore, "child", true);
stateStore.dispatch({ type: "turn/started", threadId: "child", turnId: "turn" });
await actions.startNewThread();
expect(navigation.prepareForPersistentNavigation).toHaveBeenCalledWith(null);
expect(host.identity.clearActiveThreadIdentity).not.toHaveBeenCalled();
expect(host.focusComposer).not.toHaveBeenCalled();
});
it("focuses an already open thread without resuming it", async () => {
const { actions, host } = createActionsHarness({
focusThreadInOpenView: vi.fn().mockResolvedValue(true),
@ -106,6 +127,22 @@ describe("ThreadNavigationActions", () => {
expect(host.resumeThread).not.toHaveBeenCalled();
});
it("keeps a running subagent subscribed when the selected thread is already open elsewhere", async () => {
const navigation = navigationMock();
const { actions, host, stateStore } = createActionsHarness({
navigation,
focusThreadInOpenView: vi.fn().mockResolvedValue(true),
});
resumeThreadState(stateStore, "child", true);
stateStore.dispatch({ type: "turn/started", threadId: "child", turnId: "turn" });
await actions.selectThread("other");
expect(host.focusThreadInOpenView).toHaveBeenCalledWith("other");
expect(navigation.prepareForPersistentNavigation).not.toHaveBeenCalled();
expect(host.resumeThread).not.toHaveBeenCalled();
});
it("resumes the thread when it is not already open", async () => {
const { actions, host } = createActionsHarness();
@ -115,6 +152,21 @@ describe("ThreadNavigationActions", () => {
expect(host.resumeThread).toHaveBeenCalledWith("thread");
});
it("allows switching away from a running subagent after unsubscribe preparation", async () => {
const preparation = { kind: "unsubscribe-after-resume", threadId: "child", targetThreadId: "other" } as const;
const navigation = navigationMock(preparation);
const { actions, host, stateStore } = createActionsHarness({ navigation });
resumeThreadState(stateStore, "child", true);
stateStore.dispatch({ type: "turn/started", threadId: "child", turnId: "turn" });
await actions.selectThread("other");
expect(navigation.prepareForPersistentNavigation).toHaveBeenCalledWith("other");
expect(host.closeForThreadSelection).toHaveBeenCalledOnce();
expect(host.resumeThread).toHaveBeenCalledWith("other");
expectCallBefore(host.resumeThread as ReturnType<typeof vi.fn>, navigation.completePersistentNavigation);
});
it("blocks switching away while a turn is running", async () => {
const { actions, host, stateStore } = createActionsHarness();
resumeThreadState(stateStore, "active");
@ -153,3 +205,20 @@ describe("ThreadNavigationActions", () => {
expect(host.resumeThread).not.toHaveBeenCalled();
});
});
function expectCallBefore(first: ReturnType<typeof vi.fn>, second: ReturnType<typeof vi.fn>): void {
const firstCall = first.mock.invocationCallOrder[0];
const secondCall = second.mock.invocationCallOrder[0];
if (firstCall === undefined || secondCall === undefined) throw new Error("Expected both mocks to have been called.");
expect(firstCall).toBeLessThan(secondCall);
}
function navigationMock(preparation: PersistentNavigationPreparation | null = { kind: "ready" }): PersistentNavigationLifecycle & {
prepareForPersistentNavigation: ReturnType<typeof vi.fn>;
completePersistentNavigation: ReturnType<typeof vi.fn>;
} {
return {
prepareForPersistentNavigation: vi.fn().mockResolvedValue(preparation),
completePersistentNavigation: vi.fn().mockResolvedValue(undefined),
};
}

View file

@ -897,6 +897,88 @@ describe("CodexChatView connection lifecycle", () => {
expect(requestSaveLayout).toHaveBeenCalledTimes(1);
});
it("resumes another persistent thread before unsubscribing a running subagent", async () => {
const client = connectedClient({
"thread/resume": vi.fn((params: unknown) => {
const threadId = (params as { threadId: string }).threadId;
return Promise.resolve(
threadId === "child"
? resumedThread(threadId, { parentThreadId: "parent", threadSource: "subAgentThreadSpawn" })
: resumedThread(threadId),
);
}),
"thread/unsubscribe": vi.fn().mockResolvedValue({ status: "unsubscribed" }),
});
connectionMock.state.client = client;
const view = await chatView();
await view.surface.openThread("child");
connectionMock.state.onNotification?.({
method: "turn/started",
params: {
threadId: "child",
turn: {
id: "turn-child",
status: "inProgress",
startedAt: 1,
completedAt: null,
durationMs: null,
error: null,
itemsView: "full",
items: [],
},
},
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
await view.surface.openThread("other");
const unsubscribeCall = client.request.mock.calls.findIndex(([method]) => method === "thread/unsubscribe");
const otherResumeCall = client.request.mock.calls.findIndex(
([method, params]) => method === "thread/resume" && (params as { threadId: string }).threadId === "other",
);
expect(unsubscribeCall).toBeGreaterThanOrEqual(0);
expect(unsubscribeCall).toBeGreaterThan(otherResumeCall);
expect(client.request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything());
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "other" });
});
it("keeps a running subagent subscribed when openThread cannot resume the target", async () => {
const client = connectedClient({
"thread/resume": vi.fn((params: unknown) => {
const threadId = (params as { threadId: string }).threadId;
return Promise.resolve(
threadId === "child" ? resumedThread(threadId, { parentThreadId: "parent", threadSource: "subAgentThreadSpawn" }) : null,
);
}),
"thread/unsubscribe": vi.fn().mockResolvedValue({ status: "unsubscribed" }),
});
connectionMock.state.client = client;
const view = await chatView();
await view.surface.openThread("child");
connectionMock.state.onNotification?.({
method: "turn/started",
params: {
threadId: "child",
turn: {
id: "turn-child",
status: "inProgress",
startedAt: 1,
completedAt: null,
durationMs: null,
error: null,
itemsView: "full",
items: [],
},
},
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
await view.surface.openThread("other");
expect(requestMethods(client).filter((method) => method === "thread/unsubscribe")).toEqual([]);
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "child", turnLifecycle: { kind: "running", turnId: "turn-child" } });
});
it("resets to an unstarted empty chat without starting a thread", async () => {
const requestSaveLayout = vi.fn();
const client = connectedClient();
@ -1209,7 +1291,7 @@ function startedThread(threadId: string) {
};
}
function resumedThread(threadId: string) {
function resumedThread(threadId: string, threadOverrides: Record<string, unknown> = {}) {
return {
thread: {
id: threadId,
@ -1217,6 +1299,7 @@ function resumedThread(threadId: string) {
preview: "Restored thread",
cwd: "/vault",
cliVersion: "0.0.0",
...threadOverrides,
},
cwd: "/vault",
model: null,