feat: add thinking option support and autonomous run handling
This commit is contained in:
@@ -1004,6 +1004,86 @@ describe("AgentManager", () => {
|
||||
expect(lifecycleUpdates).toContain("idle");
|
||||
});
|
||||
|
||||
test("cancelAgentRun can interrupt autonomous running state without a foreground pendingRun", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const liveEvents = new EventPushable<AgentStreamEvent>();
|
||||
|
||||
class LiveInterruptSession extends TestAgentSession {
|
||||
public interruptCount = 0;
|
||||
|
||||
async *streamLiveEvents(): AsyncGenerator<AgentStreamEvent> {
|
||||
for await (const event of liveEvents) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
override async interrupt(): Promise<void> {
|
||||
this.interruptCount += 1;
|
||||
}
|
||||
|
||||
override async close(): Promise<void> {
|
||||
liveEvents.end();
|
||||
}
|
||||
}
|
||||
|
||||
class LiveInterruptClient extends TestAgentClient {
|
||||
lastSession: LiveInterruptSession | null = null;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = new LiveInterruptSession(config);
|
||||
this.lastSession = session;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new LiveInterruptClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000129",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsubscribe = manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type !== "agent_state") {
|
||||
return;
|
||||
}
|
||||
if (event.agent.id !== snapshot.id) {
|
||||
return;
|
||||
}
|
||||
if (event.agent.lifecycle !== "running") {
|
||||
return;
|
||||
}
|
||||
unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false }
|
||||
);
|
||||
liveEvents.push({ type: "turn_started", provider: "codex" });
|
||||
});
|
||||
|
||||
const beforeCancel = manager.getAgent(snapshot.id);
|
||||
expect(beforeCancel?.lifecycle).toBe("running");
|
||||
expect(Boolean(beforeCancel && "pendingRun" in beforeCancel && beforeCancel.pendingRun)).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
const cancelled = await manager.cancelAgentRun(snapshot.id);
|
||||
expect(cancelled).toBe(true);
|
||||
expect(client.lastSession?.interruptCount).toBe(1);
|
||||
});
|
||||
|
||||
test("waitForAgentEvent waitForActive resolves for autonomous live-event run", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-wait-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
@@ -1050,6 +1130,167 @@ describe("AgentManager", () => {
|
||||
expect(result.status).toBe("idle");
|
||||
});
|
||||
|
||||
test("buffers autonomous live events during foreground run and flushes after run settles", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-buffer-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const liveEvents = new EventPushable<AgentStreamEvent>();
|
||||
const releaseForeground = deferred<void>();
|
||||
|
||||
class BufferedLiveSession extends TestAgentSession {
|
||||
override async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield { type: "turn_started", provider: this.provider };
|
||||
await releaseForeground.promise;
|
||||
yield { type: "turn_completed", provider: this.provider };
|
||||
}
|
||||
|
||||
async *streamLiveEvents(): AsyncGenerator<AgentStreamEvent> {
|
||||
for await (const event of liveEvents) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
override async close(): Promise<void> {
|
||||
liveEvents.end();
|
||||
}
|
||||
}
|
||||
|
||||
class BufferedLiveClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new BufferedLiveSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new BufferedLiveClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000127",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const foreground = manager.streamAgent(snapshot.id, "foreground run");
|
||||
const consumeForeground = (async () => {
|
||||
for await (const _event of foreground) {
|
||||
// Drain foreground stream.
|
||||
}
|
||||
})();
|
||||
|
||||
await manager.waitForAgentRunStart(snapshot.id);
|
||||
|
||||
liveEvents.push({ type: "turn_started", provider: "codex" });
|
||||
liveEvents.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "AUTONOMOUS_DURING_FOREGROUND" },
|
||||
});
|
||||
liveEvents.push({ type: "turn_completed", provider: "codex" });
|
||||
|
||||
releaseForeground.resolve();
|
||||
await consumeForeground;
|
||||
|
||||
const updated = manager.getAgent(snapshot.id);
|
||||
expect(updated?.lifecycle).toBe("idle");
|
||||
expect(manager.getTimeline(snapshot.id)).toContainEqual({
|
||||
type: "assistant_message",
|
||||
text: "AUTONOMOUS_DURING_FOREGROUND",
|
||||
});
|
||||
});
|
||||
|
||||
test("restarts live event pump after iterator failure", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-restart-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const liveEvents = new EventPushable<AgentStreamEvent>();
|
||||
|
||||
class FlakyLiveSession extends TestAgentSession {
|
||||
private attempts = 0;
|
||||
|
||||
async *streamLiveEvents(): AsyncGenerator<AgentStreamEvent> {
|
||||
this.attempts += 1;
|
||||
if (this.attempts === 1) {
|
||||
throw new Error("simulated live iterator failure");
|
||||
}
|
||||
for await (const event of liveEvents) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
override async close(): Promise<void> {
|
||||
liveEvents.end();
|
||||
}
|
||||
}
|
||||
|
||||
class FlakyLiveClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new FlakyLiveSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new FlakyLiveClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000128",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
// Give the first failed stream a chance to restart.
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const assistantSeen = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error("Timed out waiting for restarted live event"));
|
||||
}, 2_000);
|
||||
const unsubscribe = manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type !== "agent_stream" || event.agentId !== snapshot.id) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.event.type === "timeline" &&
|
||||
event.event.item.type === "assistant_message" &&
|
||||
event.event.item.text === "AUTONOMOUS_AFTER_RESTART"
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false }
|
||||
);
|
||||
});
|
||||
|
||||
liveEvents.push({ type: "turn_started", provider: "codex" });
|
||||
liveEvents.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "AUTONOMOUS_AFTER_RESTART" },
|
||||
});
|
||||
liveEvents.push({ type: "turn_completed", provider: "codex" });
|
||||
|
||||
await assistantSeen;
|
||||
const result = await manager.waitForAgentEvent(snapshot.id);
|
||||
expect(result.status).toBe("idle");
|
||||
expect(manager.getTimeline(snapshot.id)).toContainEqual({
|
||||
type: "assistant_message",
|
||||
text: "AUTONOMOUS_AFTER_RESTART",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -296,6 +296,7 @@ export class AgentManager {
|
||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly liveEventPumps = new Map<string, Promise<void>>();
|
||||
private readonly liveEventBacklog = new Map<string, AgentStreamEvent[]>();
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private logger: Logger;
|
||||
|
||||
@@ -743,6 +744,7 @@ export class AgentManager {
|
||||
// Remove the existing agent entry before swapping sessions
|
||||
this.agents.delete(agentId);
|
||||
this.liveEventPumps.delete(agentId);
|
||||
this.liveEventBacklog.delete(agentId);
|
||||
try {
|
||||
await existing.session.close();
|
||||
} catch (error) {
|
||||
@@ -773,6 +775,7 @@ export class AgentManager {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.agents.delete(agentId);
|
||||
this.liveEventPumps.delete(agentId);
|
||||
this.liveEventBacklog.delete(agentId);
|
||||
// Clean up previousStatus to prevent memory leak
|
||||
this.previousStatuses.delete(agentId);
|
||||
const session = agent.session;
|
||||
@@ -1004,6 +1007,7 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
this.emitState(mutableAgent);
|
||||
this.flushLiveEventBacklog(mutableAgent);
|
||||
};
|
||||
|
||||
const self = this;
|
||||
@@ -1147,7 +1151,11 @@ export class AgentManager {
|
||||
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const pendingRun = agent.pendingRun;
|
||||
if (!pendingRun || typeof pendingRun.return !== "function") {
|
||||
const hasForegroundPendingRun =
|
||||
Boolean(pendingRun) && typeof pendingRun?.return === "function";
|
||||
const isAutonomousRunning = agent.lifecycle === "running" && !hasForegroundPendingRun;
|
||||
|
||||
if (!hasForegroundPendingRun && !isAutonomousRunning) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1160,16 +1168,18 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Await the generator's .return() to ensure the finally block runs
|
||||
// and pendingRun is properly cleared before we return.
|
||||
await pendingRun.return(undefined as unknown as AgentStreamEvent);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ err: error, agentId },
|
||||
"Failed to cancel run"
|
||||
);
|
||||
throw error;
|
||||
if (hasForegroundPendingRun && pendingRun) {
|
||||
try {
|
||||
// Await the generator's .return() to ensure the finally block runs
|
||||
// and pendingRun is properly cleared before we return.
|
||||
await pendingRun.return(undefined as unknown as AgentStreamEvent);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ err: error, agentId },
|
||||
"Failed to cancel run"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any pending permissions that weren't cleaned up by handleStreamEvent.
|
||||
@@ -1564,6 +1574,7 @@ export class AgentManager {
|
||||
const newInfo = await agent.session.getRuntimeInfo();
|
||||
const changed =
|
||||
newInfo.model !== agent.runtimeInfo?.model ||
|
||||
newInfo.thinkingOptionId !== agent.runtimeInfo?.thinkingOptionId ||
|
||||
newInfo.sessionId !== agent.runtimeInfo?.sessionId ||
|
||||
newInfo.modeId !== agent.runtimeInfo?.modeId;
|
||||
agent.runtimeInfo = newInfo;
|
||||
@@ -1926,29 +1937,54 @@ export class AgentManager {
|
||||
if (!supportsLiveEventStream(agent.session)) {
|
||||
return;
|
||||
}
|
||||
const liveSession = agent.session;
|
||||
if (this.liveEventPumps.has(agent.id)) {
|
||||
return;
|
||||
}
|
||||
const pump = (async () => {
|
||||
try {
|
||||
for await (const event of liveSession.streamLiveEvents()) {
|
||||
const current = this.agents.get(agent.id);
|
||||
if (!current) {
|
||||
break;
|
||||
while (true) {
|
||||
const current = this.agents.get(agent.id);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
if (!supportsLiveEventStream(current.session)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const event of current.session.streamLiveEvents()) {
|
||||
const latest = this.agents.get(agent.id);
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
// Keep consuming provider events even during an active foreground run,
|
||||
// then replay them immediately once that run settles.
|
||||
if (latest.pendingRun) {
|
||||
this.enqueueLiveEvent(latest.id, event);
|
||||
continue;
|
||||
}
|
||||
this.flushLiveEventBacklog(latest);
|
||||
this.handleStreamEvent(latest, event);
|
||||
}
|
||||
// Foreground streamAgent owns live prompt events while running.
|
||||
// Background live-event tailing is for autonomous activity while idle.
|
||||
if (current.pendingRun) {
|
||||
continue;
|
||||
}
|
||||
this.handleStreamEvent(current, event);
|
||||
this.logger.warn(
|
||||
{ agentId: agent.id },
|
||||
"Live event pump stream ended; restarting"
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId: agent.id },
|
||||
"Live event pump failed"
|
||||
);
|
||||
}
|
||||
|
||||
// Keep pump alive unless the agent is gone.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const latest = this.agents.get(agent.id);
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
if (!latest.pendingRun) {
|
||||
this.flushLiveEventBacklog(latest);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId: agent.id },
|
||||
"Live event pump failed"
|
||||
);
|
||||
}
|
||||
})();
|
||||
this.liveEventPumps.set(agent.id, pump);
|
||||
@@ -1959,4 +1995,27 @@ export class AgentManager {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueLiveEvent(agentId: string, event: AgentStreamEvent): void {
|
||||
const existing = this.liveEventBacklog.get(agentId);
|
||||
if (existing) {
|
||||
existing.push(event);
|
||||
return;
|
||||
}
|
||||
this.liveEventBacklog.set(agentId, [event]);
|
||||
}
|
||||
|
||||
private flushLiveEventBacklog(agent: ActiveManagedAgent): void {
|
||||
if (agent.pendingRun) {
|
||||
return;
|
||||
}
|
||||
const pending = this.liveEventBacklog.get(agent.id);
|
||||
if (!pending || pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.liveEventBacklog.delete(agent.id);
|
||||
for (const event of pending) {
|
||||
this.handleStreamEvent(agent, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,23 @@ type ProjectionOptions = {
|
||||
internal?: boolean;
|
||||
};
|
||||
|
||||
function normalizeThinkingOptionId(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function resolveEffectiveThinkingOptionId(options: {
|
||||
runtimeInfo?: AgentRuntimeInfo | null;
|
||||
configuredThinkingOptionId?: string | null;
|
||||
}): string | null {
|
||||
const runtimeInfo = options.runtimeInfo;
|
||||
if (runtimeInfo && "thinkingOptionId" in runtimeInfo) {
|
||||
return normalizeThinkingOptionId(runtimeInfo.thinkingOptionId);
|
||||
}
|
||||
return normalizeThinkingOptionId(options.configuredThinkingOptionId);
|
||||
}
|
||||
|
||||
export function toStoredAgentRecord(
|
||||
agent: ManagedAgent,
|
||||
options?: ProjectionOptions
|
||||
@@ -67,6 +84,10 @@ export function toAgentPayload(
|
||||
): AgentSnapshotPayload {
|
||||
const runtimeInfo = sanitizeRuntimeInfo(agent.runtimeInfo);
|
||||
const thinkingOptionId = agent.config.thinkingOptionId ?? null;
|
||||
const effectiveThinkingOptionId = resolveEffectiveThinkingOptionId({
|
||||
runtimeInfo,
|
||||
configuredThinkingOptionId: thinkingOptionId,
|
||||
});
|
||||
|
||||
const payload: AgentSnapshotPayload = {
|
||||
id: agent.id,
|
||||
@@ -74,6 +95,7 @@ export function toAgentPayload(
|
||||
cwd: agent.cwd,
|
||||
model: agent.config.model ?? null,
|
||||
thinkingOptionId,
|
||||
effectiveThinkingOptionId,
|
||||
runtimeInfo,
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
@@ -287,6 +309,9 @@ function sanitizeRuntimeInfo(
|
||||
if (runtimeInfo.model !== undefined) {
|
||||
sanitized.model = runtimeInfo.model;
|
||||
}
|
||||
if (runtimeInfo.thinkingOptionId !== undefined) {
|
||||
sanitized.thinkingOptionId = runtimeInfo.thinkingOptionId;
|
||||
}
|
||||
if (runtimeInfo.modeId !== undefined) {
|
||||
sanitized.modeId = runtimeInfo.modeId;
|
||||
}
|
||||
|
||||
@@ -278,6 +278,7 @@ export type AgentRuntimeInfo = {
|
||||
provider: AgentProvider;
|
||||
sessionId: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
modeId?: string | null;
|
||||
extra?: AgentMetadata;
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
provider: z.string(),
|
||||
sessionId: z.string().nullable(),
|
||||
model: z.string().nullable().optional(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
modeId: z.string().nullable().optional(),
|
||||
extra: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
+691
-149
@@ -1,7 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
@@ -295,7 +292,7 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
const secondTurnEvents = await secondTurnPromise;
|
||||
const secondAssistantText = collectAssistantText(secondTurnEvents);
|
||||
|
||||
expect(sdkMocks.query).toHaveBeenCalledTimes(2);
|
||||
expect(sdkMocks.query.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(secondAssistantText).toContain("NEW_TURN_RESPONSE");
|
||||
expect(secondAssistantText).not.toContain("OLD_TURN_RESPONSE");
|
||||
|
||||
@@ -430,6 +427,299 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("ignores stale task-notification message_start bursts before prompt replay", async () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "task-notification-message-start-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "task-msg-start-1",
|
||||
status: "completed",
|
||||
output_file: "/tmp/task-msg-start-1.txt",
|
||||
summary: "Background task finished",
|
||||
session_id: "task-notification-message-start-session",
|
||||
uuid: "task-msg-start-note-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: null,
|
||||
event: {
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "stale-msg-start-1",
|
||||
role: "assistant",
|
||||
model: "opus",
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: "STALE_MESSAGE_START_RESPONSE",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 4) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 5) {
|
||||
step += 1;
|
||||
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "current prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: promptUuid,
|
||||
session_id: "task-notification-message-start-session",
|
||||
isReplay: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 6) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: "CURRENT_AFTER_MESSAGE_START",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 7) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const events = await collectUntilTerminal(session.stream("current prompt"));
|
||||
const assistantText = collectAssistantText(events);
|
||||
|
||||
expect(assistantText).toContain("CURRENT_AFTER_MESSAGE_START");
|
||||
expect(assistantText).not.toContain("STALE_MESSAGE_START_RESPONSE");
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("ignores stale user-shaped task-notification message_start bursts before prompt replay", async () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "task-notification-user-message-start-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
"<task-notification>\n<task-id>task-msg-start-user-1</task-id>\n</task-notification>",
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: "task-msg-start-user-1",
|
||||
session_id: "task-notification-user-message-start-session",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: null,
|
||||
event: {
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "stale-user-msg-start-1",
|
||||
role: "assistant",
|
||||
model: "opus",
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: "STALE_USER_TASK_NOTIFICATION_RESPONSE",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 4) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 5) {
|
||||
step += 1;
|
||||
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "current prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: promptUuid,
|
||||
session_id: "task-notification-user-message-start-session",
|
||||
isReplay: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 6) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: "CURRENT_AFTER_USER_MESSAGE_START",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 7) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const events = await collectUntilTerminal(session.stream("current prompt"));
|
||||
const assistantText = collectAssistantText(events);
|
||||
|
||||
expect(assistantText).toContain("CURRENT_AFTER_USER_MESSAGE_START");
|
||||
expect(assistantText).not.toContain("STALE_USER_TASK_NOTIFICATION_RESPONSE");
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("does not terminate the current prompt on a stale pre-prompt result event", async () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
@@ -528,156 +818,408 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("emits autonomous live events from history tail when Claude wakes itself", async () => {
|
||||
test("emits autonomous live events from SDK stream when Claude wakes itself", async () => {
|
||||
const logger = createTestLogger();
|
||||
const projectDir = mkdtempSync(path.join(tmpdir(), "claude-live-tail-project-"));
|
||||
const configDir = mkdtempSync(path.join(tmpdir(), "claude-live-tail-config-"));
|
||||
const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
let queryCreateCount = 0;
|
||||
let localPromptUuid: string | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(() => {
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "live-autonomous-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
sdkMocks.query.mockImplementation(
|
||||
({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryCreateCount += 1;
|
||||
if (queryCreateCount === 1) {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "live-autonomous-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
localPromptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "seed prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: localPromptUuid,
|
||||
session_id: "live-autonomous-session",
|
||||
isReplay: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: { content: "SEED_DONE" },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
}
|
||||
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n</task-notification>",
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: "task-note-user-1",
|
||||
session_id: "live-autonomous-session",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
}
|
||||
);
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
try {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: projectDir,
|
||||
});
|
||||
|
||||
await collectUntilTerminal(session.stream("seed prompt"));
|
||||
expect(session.describePersistence()?.sessionId).toBe(
|
||||
"live-autonomous-session"
|
||||
);
|
||||
|
||||
const sanitizedCwd = projectDir.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
|
||||
const historyDir = path.join(configDir, "projects", sanitizedCwd);
|
||||
mkdirSync(historyDir, { recursive: true });
|
||||
const historyPath = path.join(historyDir, "live-autonomous-session.jsonl");
|
||||
writeFileSync(historyPath, "", "utf8");
|
||||
|
||||
const liveIterator = (
|
||||
session as unknown as {
|
||||
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
|
||||
}
|
||||
).streamLiveEvents();
|
||||
const timedReader = createTimedIteratorReader({ iterator: liveIterator });
|
||||
const appendAutonomousBurst = (suffix: string) => {
|
||||
appendFileSync(
|
||||
historyPath,
|
||||
`${JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: `task-${suffix}`,
|
||||
status: "completed",
|
||||
output_file: "/tmp/out.txt",
|
||||
summary: "Background task finished",
|
||||
uuid: `task-note-uuid-${suffix}`,
|
||||
session_id: "live-autonomous-session",
|
||||
})}\n`
|
||||
);
|
||||
appendFileSync(
|
||||
historyPath,
|
||||
`${JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: "AUTONOMOUS_WAKE_RESPONSE" },
|
||||
uuid: `assistant-uuid-${suffix}`,
|
||||
session_id: "live-autonomous-session",
|
||||
})}\n`
|
||||
);
|
||||
appendFileSync(
|
||||
historyPath,
|
||||
`${JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
uuid: `result-uuid-${suffix}`,
|
||||
session_id: "live-autonomous-session",
|
||||
})}\n`
|
||||
);
|
||||
};
|
||||
|
||||
const liveEvents: AgentStreamEvent[] = [];
|
||||
let completed = false;
|
||||
for (let attempt = 0; attempt < 4 && !completed; attempt += 1) {
|
||||
appendAutonomousBurst(String(attempt));
|
||||
while (liveEvents.length < 12) {
|
||||
let next: IteratorResult<AgentStreamEvent>;
|
||||
try {
|
||||
next = await timedReader.nextWithTimeout(2_000);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
liveEvents.push(next.value);
|
||||
if (next.value.type === "turn_completed") {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await collectUntilTerminal(session.stream("seed prompt"));
|
||||
expect(localPromptUuid).toBeTruthy();
|
||||
expect(session.describePersistence()?.sessionId).toBe("live-autonomous-session");
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const activeTurnPromise = (
|
||||
session as unknown as { activeTurnPromise?: Promise<void> | null }
|
||||
).activeTurnPromise;
|
||||
if (!activeTurnPromise) {
|
||||
break;
|
||||
}
|
||||
|
||||
expect(liveEvents.some((event) => event.type === "turn_started")).toBe(true);
|
||||
expect(
|
||||
liveEvents.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("AUTONOMOUS_WAKE_RESPONSE")
|
||||
)
|
||||
).toBe(true);
|
||||
expect(liveEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
|
||||
await session.close();
|
||||
} finally {
|
||||
if (previousClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir;
|
||||
}
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
expect(
|
||||
(session as unknown as { activeTurnPromise?: Promise<void> | null })
|
||||
.activeTurnPromise ?? null
|
||||
).toBeNull();
|
||||
|
||||
const liveIterator = (
|
||||
session as unknown as {
|
||||
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
|
||||
}
|
||||
).streamLiveEvents();
|
||||
const timedReader = createTimedIteratorReader({ iterator: liveIterator });
|
||||
const liveEvents: AgentStreamEvent[] = [];
|
||||
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const next = await timedReader.nextWithTimeout(5_000);
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
liveEvents.push(next.value);
|
||||
if (next.value.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(liveEvents.some((event) => event.type === "turn_started")).toBe(true);
|
||||
expect(
|
||||
liveEvents.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("AUTONOMOUS_WAKE_RESPONSE")
|
||||
)
|
||||
).toBe(true);
|
||||
expect(liveEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
|
||||
await liveIterator.return?.();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("releases local-turn suppression when task notifications arrive as user payloads", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryCreateCount = 0;
|
||||
let localPromptUuid: string | null = null;
|
||||
|
||||
sdkMocks.query.mockImplementation(
|
||||
({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
queryCreateCount += 1;
|
||||
if (queryCreateCount === 1) {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "live-task-user-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
localPromptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "seed prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: localPromptUuid,
|
||||
session_id: "live-task-user-session",
|
||||
isReplay: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: { content: "SEED_DONE" },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
}
|
||||
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "seed prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: localPromptUuid ?? "missing-prompt-uuid",
|
||||
session_id: "live-task-user-session",
|
||||
isReplay: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: { content: "SHOULD_STAY_SUPPRESSED" },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n</task-notification>",
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: "task-note-user-1",
|
||||
session_id: "live-task-user-session",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: { content: "AUTONOMOUS_AFTER_TASK_NOTIFICATION" },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 4) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
}
|
||||
);
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
await collectUntilTerminal(session.stream("seed prompt"));
|
||||
expect(localPromptUuid).toBeTruthy();
|
||||
expect(session.describePersistence()?.sessionId).toBe("live-task-user-session");
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const activeTurnPromise = (
|
||||
session as unknown as { activeTurnPromise?: Promise<void> | null }
|
||||
).activeTurnPromise;
|
||||
if (!activeTurnPromise) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
expect(
|
||||
(session as unknown as { activeTurnPromise?: Promise<void> | null })
|
||||
.activeTurnPromise ?? null
|
||||
).toBeNull();
|
||||
|
||||
const liveIterator = (
|
||||
session as unknown as {
|
||||
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
|
||||
}
|
||||
).streamLiveEvents();
|
||||
const timedReader = createTimedIteratorReader({ iterator: liveIterator });
|
||||
const liveEvents: AgentStreamEvent[] = [];
|
||||
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const next = await timedReader.nextWithTimeout(5_000);
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
liveEvents.push(next.value);
|
||||
if (next.value.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
liveEvents.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("SHOULD_STAY_SUPPRESSED")
|
||||
)
|
||||
).toBe(false);
|
||||
expect(liveEvents.some((event) => event.type === "turn_started")).toBe(true);
|
||||
expect(
|
||||
liveEvents.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("AUTONOMOUS_AFTER_TASK_NOTIFICATION")
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
await liveIterator.return?.();
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: sdkMocks.query,
|
||||
}));
|
||||
|
||||
type QueryMock = {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
interrupt: ReturnType<typeof vi.fn>;
|
||||
return: ReturnType<typeof vi.fn>;
|
||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||
setModel: ReturnType<typeof vi.fn>;
|
||||
supportedModels: ReturnType<typeof vi.fn>;
|
||||
supportedCommands: ReturnType<typeof vi.fn>;
|
||||
rewindFiles: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function buildUsage() {
|
||||
return {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createPromptUuidReader(prompt: AsyncIterable<unknown>) {
|
||||
const iterator = prompt[Symbol.asyncIterator]();
|
||||
let cached: Promise<string | null> | null = null;
|
||||
return async () => {
|
||||
if (!cached) {
|
||||
cached = iterator.next().then((next) => {
|
||||
if (next.done) {
|
||||
return null;
|
||||
}
|
||||
const value = next.value as { uuid?: unknown } | undefined;
|
||||
return typeof value?.uuid === "string" ? value.uuid : null;
|
||||
});
|
||||
}
|
||||
return cached;
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseQueryMock(nextImpl: QueryMock["next"]): QueryMock {
|
||||
return {
|
||||
next: nextImpl,
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
};
|
||||
}
|
||||
|
||||
async function createSession() {
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
return client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
async function collectUntilTerminal(
|
||||
stream: AsyncGenerator<AgentStreamEvent>
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
if (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
check: () => boolean,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (check()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
|
||||
describe("ClaudeAgentSession redesign invariants", () => {
|
||||
beforeEach(() => {
|
||||
sdkMocks.query.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sdkMocks.query.mockReset();
|
||||
});
|
||||
|
||||
test("routes by deterministic identifier priority: task_id > parent_message_id > message_id", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
createRun: (owner: "autonomous", queue: null) => { id: string };
|
||||
runTracker: {
|
||||
bindIdentifiers: (
|
||||
run: { id: string },
|
||||
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null }
|
||||
) => void;
|
||||
};
|
||||
routeMessage: (input: {
|
||||
message: AgentStreamEvent | Record<string, unknown>;
|
||||
identifiers: { taskId: string | null; parentMessageId: string | null; messageId: string | null };
|
||||
metadataOnly: boolean;
|
||||
}) => { run: { id: string } | null; reason: string };
|
||||
turnState: "autonomous";
|
||||
};
|
||||
|
||||
const taskRun = internal.createRun("autonomous", null);
|
||||
internal.runTracker.bindIdentifiers(taskRun, {
|
||||
taskId: "task-A",
|
||||
parentMessageId: null,
|
||||
messageId: "msg-A",
|
||||
});
|
||||
|
||||
const parentRun = internal.createRun("autonomous", null);
|
||||
internal.runTracker.bindIdentifiers(parentRun, {
|
||||
taskId: null,
|
||||
parentMessageId: "parent-B",
|
||||
messageId: "msg-B",
|
||||
});
|
||||
|
||||
const messageRun = internal.createRun("autonomous", null);
|
||||
internal.runTracker.bindIdentifiers(messageRun, {
|
||||
taskId: null,
|
||||
parentMessageId: null,
|
||||
messageId: "msg-C",
|
||||
});
|
||||
|
||||
internal.turnState = "autonomous";
|
||||
|
||||
const taskPriorityRoute = internal.routeMessage({
|
||||
message: { type: "assistant", message: { content: "task-priority" } },
|
||||
identifiers: {
|
||||
taskId: "task-A",
|
||||
parentMessageId: "parent-B",
|
||||
messageId: "msg-C",
|
||||
},
|
||||
metadataOnly: false,
|
||||
});
|
||||
expect(taskPriorityRoute.reason).toBe("task_id");
|
||||
expect(taskPriorityRoute.run?.id).toBe(taskRun.id);
|
||||
|
||||
const parentPriorityRoute = internal.routeMessage({
|
||||
message: { type: "assistant", message: { content: "parent-priority" } },
|
||||
identifiers: {
|
||||
taskId: null,
|
||||
parentMessageId: "parent-B",
|
||||
messageId: "msg-C",
|
||||
},
|
||||
metadataOnly: false,
|
||||
});
|
||||
expect(parentPriorityRoute.reason).toBe("parent_message_id");
|
||||
expect(parentPriorityRoute.run?.id).toBe(parentRun.id);
|
||||
|
||||
const messagePriorityRoute = internal.routeMessage({
|
||||
message: { type: "assistant", message: { content: "message-priority" } },
|
||||
identifiers: {
|
||||
taskId: null,
|
||||
parentMessageId: null,
|
||||
messageId: "msg-C",
|
||||
},
|
||||
metadataOnly: false,
|
||||
});
|
||||
expect(messagePriorityRoute.reason).toBe("message_id");
|
||||
expect(messagePriorityRoute.run?.id).toBe(messageRun.id);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("does not route unbound events to foreground before prompt replay is observed", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
createRun: (owner: "foreground" | "autonomous", queue: unknown) => { id: string };
|
||||
runTracker: {
|
||||
bindIdentifiers: (
|
||||
run: { id: string },
|
||||
ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null }
|
||||
) => void;
|
||||
};
|
||||
activeForegroundTurn: { runId: string; queue: unknown } | null;
|
||||
turnState: "foreground";
|
||||
routeMessage: (input: {
|
||||
message: Record<string, unknown>;
|
||||
identifiers: { taskId: string | null; parentMessageId: string | null; messageId: string | null };
|
||||
metadataOnly: boolean;
|
||||
}) => { run: { id: string } | null; reason: string };
|
||||
};
|
||||
|
||||
const foregroundQueueStub = {
|
||||
push: () => undefined,
|
||||
end: () => undefined,
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => ({ done: true as const, value: undefined }),
|
||||
}),
|
||||
};
|
||||
const foregroundRun = internal.createRun("foreground", foregroundQueueStub);
|
||||
internal.turnState = "foreground";
|
||||
internal.activeForegroundTurn = {
|
||||
runId: foregroundRun.id,
|
||||
queue: foregroundQueueStub,
|
||||
};
|
||||
|
||||
const unboundBeforeReplay = internal.routeMessage({
|
||||
message: { type: "assistant", message: { content: "stale-before-replay" } },
|
||||
identifiers: {
|
||||
taskId: null,
|
||||
parentMessageId: null,
|
||||
messageId: null,
|
||||
},
|
||||
metadataOnly: false,
|
||||
});
|
||||
expect(unboundBeforeReplay.reason).toBe("fallback");
|
||||
expect(unboundBeforeReplay.run?.id).not.toBe(foregroundRun.id);
|
||||
|
||||
const promptReplayId = "foreground-replay-id";
|
||||
internal.runTracker.bindIdentifiers(foregroundRun, {
|
||||
taskId: null,
|
||||
parentMessageId: null,
|
||||
messageId: promptReplayId,
|
||||
});
|
||||
const replayRoute = internal.routeMessage({
|
||||
message: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "prompt replay" },
|
||||
uuid: promptReplayId,
|
||||
},
|
||||
identifiers: {
|
||||
taskId: null,
|
||||
parentMessageId: null,
|
||||
messageId: promptReplayId,
|
||||
},
|
||||
metadataOnly: false,
|
||||
});
|
||||
expect(replayRoute.reason).toBe("message_id");
|
||||
expect(replayRoute.run?.id).toBe(foregroundRun.id);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("tracks run lifecycle transitions for success, error, and interrupt", async () => {
|
||||
const session = await createSession();
|
||||
let streamCase: "success" | "error" | "interrupt" = "success";
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
let interruptRequested = false;
|
||||
|
||||
const mock = createBaseQueryMock(
|
||||
vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "redesign-lifecycle-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "prompt replay" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: (await readPromptUuid()) ?? "missing-prompt-uuid",
|
||||
session_id: "redesign-lifecycle-session",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: { content: "assistant output" },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (streamCase === "interrupt") {
|
||||
if (!interruptRequested) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: { content: "waiting for interrupt" },
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
if (streamCase === "success") {
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "error",
|
||||
usage: buildUsage(),
|
||||
errors: ["simulated failure"],
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
})
|
||||
);
|
||||
|
||||
mock.interrupt.mockImplementation(async () => {
|
||||
interruptRequested = true;
|
||||
});
|
||||
return mock;
|
||||
});
|
||||
|
||||
streamCase = "success";
|
||||
const successEvents = await collectUntilTerminal(session.stream("success prompt"));
|
||||
expect(successEvents.some((event) => event.type === "turn_completed")).toBe(
|
||||
true
|
||||
);
|
||||
expect(successEvents.some((event) => event.type === "turn_failed")).toBe(
|
||||
false
|
||||
);
|
||||
expect(successEvents.some((event) => event.type === "turn_canceled")).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
streamCase = "error";
|
||||
const errorEvents = await collectUntilTerminal(session.stream("error prompt"));
|
||||
expect(errorEvents.some((event) => event.type === "turn_failed")).toBe(true);
|
||||
expect(errorEvents.some((event) => event.type === "turn_completed")).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
streamCase = "interrupt";
|
||||
const interruptStream = session.stream("interrupt prompt");
|
||||
const interruptEvents: AgentStreamEvent[] = [];
|
||||
for await (const event of interruptStream) {
|
||||
interruptEvents.push(event);
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message"
|
||||
) {
|
||||
await session.interrupt();
|
||||
}
|
||||
if (event.type === "turn_canceled") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(interruptEvents.some((event) => event.type === "turn_canceled")).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("assembles assistant timeline when message_delta arrives before message_start", async () => {
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return createBaseQueryMock(
|
||||
vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "redesign-timeline-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "timeline prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: (await readPromptUuid()) ?? "missing-prompt-uuid",
|
||||
session_id: "redesign-timeline-session",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_delta",
|
||||
delta: { type: "text_delta", text: "HELLO " },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "message_start",
|
||||
message: { id: "message-1", role: "assistant", model: "opus" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 4) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_delta",
|
||||
message_id: "message-1",
|
||||
delta: { type: "text_delta", text: "WORLD" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 5) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "message_stop",
|
||||
message_id: "message-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 6) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const session = await createSession();
|
||||
const events = await collectUntilTerminal(session.stream("timeline prompt"));
|
||||
const assistantText = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message"
|
||||
)
|
||||
.map((event) => event.item.text)
|
||||
.join("");
|
||||
|
||||
expect(assistantText).toContain("HELLO WORLD");
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("does not use stream_event uuid as assistant message identity when message_id is missing", async () => {
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return createBaseQueryMock(
|
||||
vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "redesign-stream-event-uuid-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "uuid fallback prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: (await readPromptUuid()) ?? "missing-prompt-uuid",
|
||||
session_id: "redesign-stream-event-uuid-session",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
uuid: "stream-event-uuid-1",
|
||||
event: {
|
||||
type: "message_start",
|
||||
message: { role: "assistant", model: "opus" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
uuid: "stream-event-uuid-2",
|
||||
event: {
|
||||
type: "content_block_delta",
|
||||
delta: { type: "text_delta", text: "HELLO " },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 4) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
uuid: "stream-event-uuid-3",
|
||||
event: {
|
||||
type: "content_block_delta",
|
||||
delta: { type: "text_delta", text: "WORLD" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 5) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
uuid: "stream-event-uuid-4",
|
||||
event: {
|
||||
type: "message_stop",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 6) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const session = await createSession();
|
||||
const events = await collectUntilTerminal(session.stream("uuid fallback prompt"));
|
||||
const assistantText = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message"
|
||||
)
|
||||
.map((event) => event.item.text)
|
||||
.join("");
|
||||
|
||||
expect(assistantText).toContain("HELLO WORLD");
|
||||
|
||||
const assembler = session as unknown as {
|
||||
timelineAssembler: { messages: Map<string, unknown> };
|
||||
};
|
||||
expect(assembler.timelineAssembler.messages.size).toBe(1);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("surfaces autonomous running transitions through manager-visible events during foreground overlap", async () => {
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return createBaseQueryMock(
|
||||
vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "redesign-manager-overlap-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "foreground prompt replay" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: (await readPromptUuid()) ?? "missing-prompt-uuid",
|
||||
session_id: "redesign-manager-overlap-session",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: { content: "FOREGROUND_DONE" },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 4) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n</task-notification>",
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: "task-note-overlap-1",
|
||||
session_id: "redesign-manager-overlap-session",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 5) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: { content: "AUTONOMOUS_OVERLAP_DONE" },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 6) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const logger = createTestLogger();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
claude: new ClaudeAgentClient({ logger }),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const runningStateEvents: string[] = [];
|
||||
const turnStartedEvents: AgentStreamEvent[] = [];
|
||||
const unsubscribe = manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && event.agent.id === agent.id) {
|
||||
if (event.agent.lifecycle === "running") {
|
||||
runningStateEvents.push(event.agent.lifecycle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.type === "agent_stream" &&
|
||||
event.agentId === agent.id &&
|
||||
event.event.type === "turn_started"
|
||||
) {
|
||||
turnStartedEvents.push(event.event);
|
||||
}
|
||||
},
|
||||
{ agentId: agent.id, replayState: true }
|
||||
);
|
||||
|
||||
const foregroundEvents = await collectUntilTerminal(
|
||||
manager.streamAgent(agent.id, "foreground prompt")
|
||||
);
|
||||
expect(
|
||||
foregroundEvents.some((event) => event.type === "turn_completed")
|
||||
).toBe(true);
|
||||
|
||||
await waitForCondition(() => turnStartedEvents.length >= 2, 5_000);
|
||||
await waitForCondition(() => runningStateEvents.length >= 2, 5_000);
|
||||
|
||||
const timeline = manager.getTimeline(agent.id);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "assistant_message" &&
|
||||
item.text.includes("FOREGROUND_DONE")
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "assistant_message" &&
|
||||
item.text.includes("AUTONOMOUS_OVERLAP_DONE")
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
unsubscribe();
|
||||
await manager.closeAgent(agent.id);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,12 @@ const ClaudeToolDetailPass2Schema = z.union([
|
||||
toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
),
|
||||
toolDetailBranchByName("Glob", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
),
|
||||
toolDetailBranchByName("glob", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
),
|
||||
ClaudeSpeakToolDetailSchema,
|
||||
]);
|
||||
|
||||
|
||||
@@ -226,6 +226,25 @@ describe("claude tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Glob calls as search detail using pattern input", () => {
|
||||
const item = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
callId: "claude-glob-1",
|
||||
name: "Glob",
|
||||
input: { pattern: "**/.claude/commands/paseo*" },
|
||||
output: { output: "No files found" },
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
expect(item.error).toBeNull();
|
||||
expect(item.name).toBe("Glob");
|
||||
expect(item.detail).toEqual({
|
||||
type: "search",
|
||||
query: "**/.claude/commands/paseo*",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes claude speak tool names through schema transforms", () => {
|
||||
const item = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
|
||||
@@ -75,6 +75,8 @@ const ClaudeSearchToolNameSchema = z.union([
|
||||
z.literal("WebSearch"),
|
||||
z.literal("web_search"),
|
||||
z.literal("search"),
|
||||
z.literal("Glob"),
|
||||
z.literal("glob"),
|
||||
]);
|
||||
const ClaudeSpeakToolNameSchema = z
|
||||
.string()
|
||||
|
||||
@@ -2229,6 +2229,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: this.currentThreadId,
|
||||
model: this.config.model ?? null,
|
||||
thinkingOptionId: normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null,
|
||||
modeId: this.currentMode ?? null,
|
||||
extra: this.resolvedCollaborationMode
|
||||
? { collaborationMode: this.resolvedCollaborationMode.name }
|
||||
@@ -2434,22 +2435,39 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
if (!this.client) return;
|
||||
if (this.currentThreadId) return;
|
||||
|
||||
// Resolve model - if not specified, query available models and pick default
|
||||
// Resolve model + thinking defaults when omitted.
|
||||
let configuredDefaults: CodexConfiguredDefaults = {};
|
||||
let model = this.config.model;
|
||||
if (!model) {
|
||||
const configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
|
||||
model = configuredDefaults.model;
|
||||
let thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
if (!model || !thinkingOptionId) {
|
||||
configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
|
||||
}
|
||||
if (!model) {
|
||||
model = configuredDefaults.model;
|
||||
}
|
||||
if (!thinkingOptionId) {
|
||||
thinkingOptionId = configuredDefaults.thinkingOptionId;
|
||||
}
|
||||
|
||||
if (!model || !thinkingOptionId) {
|
||||
const modelResponse = (await this.client.request("model/list", {})) as CodexModelListResponse;
|
||||
const models = modelResponse?.data ?? [];
|
||||
const defaultModel = models.find((m) => m.isDefault) ?? models[0];
|
||||
if (!defaultModel) {
|
||||
throw new Error("No models available from Codex app-server");
|
||||
}
|
||||
model = defaultModel.id;
|
||||
const selectedModel =
|
||||
(model ? models.find((candidate) => candidate.id === model) : undefined) ?? defaultModel;
|
||||
if (!model) {
|
||||
model = selectedModel.id;
|
||||
}
|
||||
if (!thinkingOptionId) {
|
||||
thinkingOptionId = normalizeCodexThinkingOptionId(selectedModel.defaultReasoningEffort);
|
||||
}
|
||||
}
|
||||
|
||||
this.config.model = model;
|
||||
this.config.thinkingOptionId = thinkingOptionId;
|
||||
|
||||
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
|
||||
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
|
||||
|
||||
@@ -509,6 +509,7 @@ export const ToolEditOutputSchema = z.union([
|
||||
export const ToolSearchInputSchema = z.union([
|
||||
z.object({ query: z.string() }).passthrough().transform((value) => ({ query: value.query })),
|
||||
z.object({ q: z.string() }).passthrough().transform((value) => ({ query: value.q })),
|
||||
z.object({ pattern: z.string() }).passthrough().transform((value) => ({ query: value.pattern })),
|
||||
]);
|
||||
|
||||
export type ParsedToolShellInput = z.infer<typeof ToolShellInputSchema>;
|
||||
|
||||
@@ -0,0 +1,811 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||
import { getFullAccessConfig } from "./agent-configs.js";
|
||||
import { isCommandAvailable } from "../agent/provider-launch-config.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-claude-autonomous-wake-"));
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function sanitizeClaudeProjectPath(cwd: string): string {
|
||||
return cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
|
||||
}
|
||||
|
||||
function resolveClaudeTranscriptPath(params: {
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
}): string {
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(homedir(), ".claude");
|
||||
const cwdCandidates = [params.cwd];
|
||||
try {
|
||||
const realpath = realpathSync(params.cwd);
|
||||
if (!cwdCandidates.includes(realpath)) {
|
||||
cwdCandidates.push(realpath);
|
||||
}
|
||||
} catch {
|
||||
// ignore realpath errors for temporary cwd teardown races
|
||||
}
|
||||
|
||||
for (const cwd of cwdCandidates) {
|
||||
const candidate = path.join(
|
||||
configDir,
|
||||
"projects",
|
||||
sanitizeClaudeProjectPath(cwd),
|
||||
`${params.sessionId}.jsonl`
|
||||
);
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(
|
||||
configDir,
|
||||
"projects",
|
||||
sanitizeClaudeProjectPath(params.cwd),
|
||||
`${params.sessionId}.jsonl`
|
||||
);
|
||||
}
|
||||
|
||||
type ClaudeTranscriptLine = {
|
||||
type?: unknown;
|
||||
timestamp?: unknown;
|
||||
uuid?: unknown;
|
||||
parentUuid?: unknown;
|
||||
message?: { content?: unknown } | null;
|
||||
};
|
||||
|
||||
function readTranscriptLines(pathname: string): ClaudeTranscriptLine[] {
|
||||
if (!existsSync(pathname)) {
|
||||
return [];
|
||||
}
|
||||
const text = readFileSync(pathname, "utf8");
|
||||
const rows: ClaudeTranscriptLine[] = [];
|
||||
for (const line of text.split(/\n+/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
rows.push(JSON.parse(trimmed) as ClaudeTranscriptLine);
|
||||
} catch {
|
||||
// ignore malformed rows while transcript is still being appended
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function readUserText(line: ClaudeTranscriptLine): string {
|
||||
const content = line.message?.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return content
|
||||
.map((chunk) => {
|
||||
if (!chunk || typeof chunk !== "object") {
|
||||
return "";
|
||||
}
|
||||
const text = (chunk as { text?: unknown }).text;
|
||||
if (typeof text === "string") {
|
||||
return text;
|
||||
}
|
||||
const nested = (chunk as { content?: unknown }).content;
|
||||
return typeof nested === "string" ? nested : "";
|
||||
})
|
||||
.join(" ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function readAssistantText(line: ClaudeTranscriptLine): string {
|
||||
const content = line.message?.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return content
|
||||
.map((chunk) => {
|
||||
if (!chunk || typeof chunk !== "object") {
|
||||
return "";
|
||||
}
|
||||
const text = (chunk as { text?: unknown }).text;
|
||||
if (typeof text === "string") {
|
||||
return text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseTimestamp(line: ClaudeTranscriptLine): number {
|
||||
const timestamp = line.timestamp;
|
||||
if (typeof timestamp !== "string") {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Date.parse(timestamp);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
type TranscriptRaceEvidence = {
|
||||
helloAssistantText: string;
|
||||
notificationOutcomeAssistantText: string;
|
||||
};
|
||||
|
||||
function extractTranscriptRaceEvidence(
|
||||
lines: ClaudeTranscriptLine[]
|
||||
): TranscriptRaceEvidence | null {
|
||||
const userLines = lines.filter((line) => line.type === "user");
|
||||
const assistantLines = lines.filter((line) => line.type === "assistant");
|
||||
const doItAgain = [...userLines]
|
||||
.reverse()
|
||||
.find((line) => readUserText(line).toLowerCase().includes("do it again"));
|
||||
const helloUser = [...userLines]
|
||||
.reverse()
|
||||
.find((line) => readUserText(line).trim().toLowerCase() === "hello");
|
||||
|
||||
if (!doItAgain || !helloUser || typeof helloUser.uuid !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const helloAssistant = assistantLines.find(
|
||||
(line) =>
|
||||
typeof line.parentUuid === "string" &&
|
||||
line.parentUuid === helloUser.uuid &&
|
||||
readAssistantText(line).length > 0
|
||||
);
|
||||
if (!helloAssistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doItAgainTs = parseTimestamp(doItAgain);
|
||||
const taskNotificationUser = userLines.find((line) => {
|
||||
if (parseTimestamp(line) <= doItAgainTs) {
|
||||
return false;
|
||||
}
|
||||
return readUserText(line).includes("<task-notification>");
|
||||
});
|
||||
if (!taskNotificationUser || typeof taskNotificationUser.uuid !== "string") {
|
||||
return null;
|
||||
}
|
||||
const helloAssistantUuid =
|
||||
typeof helloAssistant.uuid === "string" ? helloAssistant.uuid : null;
|
||||
const taskNotificationTs = parseTimestamp(taskNotificationUser);
|
||||
const notificationOutcomeAssistant = [...assistantLines]
|
||||
.sort((a, b) => parseTimestamp(a) - parseTimestamp(b))
|
||||
.find((line) => {
|
||||
const lineTs = parseTimestamp(line);
|
||||
if (lineTs < taskNotificationTs) {
|
||||
return false;
|
||||
}
|
||||
const lineUuid = typeof line.uuid === "string" ? line.uuid : null;
|
||||
if (helloAssistantUuid && lineUuid === helloAssistantUuid) {
|
||||
return false;
|
||||
}
|
||||
return readAssistantText(line).length > 0;
|
||||
});
|
||||
if (!notificationOutcomeAssistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
helloAssistantText: readAssistantText(helloAssistant),
|
||||
notificationOutcomeAssistantText: readAssistantText(notificationOutcomeAssistant),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function summarizeTranscriptTail(lines: ClaudeTranscriptLine[], limit = 14): string {
|
||||
const tail = lines.slice(-limit);
|
||||
return tail
|
||||
.map((line) => {
|
||||
const ts = typeof line.timestamp === "string" ? line.timestamp : "";
|
||||
const type = typeof line.type === "string" ? line.type : "unknown";
|
||||
if (type === "user") {
|
||||
return `${ts} user ${readUserText(line).slice(0, 160)}`;
|
||||
}
|
||||
if (type === "assistant") {
|
||||
return `${ts} assistant ${readAssistantText(line).slice(0, 160)}`;
|
||||
}
|
||||
if (type === "queue-operation") {
|
||||
const op = (line as { operation?: unknown }).operation;
|
||||
const content = (line as { content?: unknown }).content;
|
||||
return `${ts} queue ${typeof op === "string" ? op : "unknown"} ${typeof content === "string" ? content.slice(0, 120) : ""}`;
|
||||
}
|
||||
return `${ts} ${type}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function runPreHelloNoise(params: {
|
||||
wsUrl: string;
|
||||
durationMs: number;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + params.durationMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const ws = new WebSocket(params.wsUrl);
|
||||
const fallback = setTimeout(() => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve();
|
||||
}, 250);
|
||||
|
||||
ws.once("open", () => {
|
||||
try {
|
||||
// Intentionally send a session message before hello to create the same
|
||||
// pre-hello churn seen in production logs.
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
payload: {
|
||||
type: "fetch_agents_request",
|
||||
requestId: `noise-${Date.now()}`,
|
||||
},
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 5);
|
||||
});
|
||||
|
||||
ws.once("close", () => {
|
||||
clearTimeout(fallback);
|
||||
resolve();
|
||||
});
|
||||
ws.once("error", () => {
|
||||
clearTimeout(fallback);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeTimelineEntry(entry: {
|
||||
item:
|
||||
| { type: "user_message"; text: string }
|
||||
| { type: "assistant_message"; text: string }
|
||||
| { type: "tool_call"; name: string; status: string }
|
||||
| { type: string };
|
||||
}): string {
|
||||
const item = entry.item;
|
||||
if (item.type === "user_message") {
|
||||
return `[user] ${item.text}`;
|
||||
}
|
||||
if (item.type === "assistant_message") {
|
||||
return `[assistant] ${item.text}`;
|
||||
}
|
||||
if (item.type === "tool_call") {
|
||||
return `[tool:${item.name}:${item.status}]`;
|
||||
}
|
||||
return `[${item.type}]`;
|
||||
}
|
||||
|
||||
describe("daemon E2E (real claude) - autonomous wake from background task", () => {
|
||||
test.runIf(isCommandAvailable("claude"))(
|
||||
"returns to running after background sleep completes without a second prompt",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "claude-autonomous-wake-real" },
|
||||
});
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-autonomous-wake-real",
|
||||
...getFullAccessConfig("claude"),
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Use a background task to run exactly: sleep 5",
|
||||
"Do not wait for it to finish.",
|
||||
"After launching it, reply with exactly: SPAWNED",
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
const firstFinish = await client.waitForFinish(agent.id, 240_000);
|
||||
expect(firstFinish.status).toBe("idle");
|
||||
|
||||
const timelineBeforeWake = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const summarized = timelineBeforeWake.entries.map(summarizeTimelineEntry);
|
||||
// Required by reproduction request: log timeline at idle edge before autonomous wake.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("TIMELINE_BEFORE_AUTONOMOUS_WAKE\n" + summarized.join("\n"));
|
||||
expect(summarized.some((line) => line.includes("SPAWNED"))).toBe(true);
|
||||
|
||||
// No new user prompt here: we expect autonomous transition caused by
|
||||
// background task completion notification.
|
||||
await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
420_000
|
||||
);
|
||||
|
||||
test.runIf(isCommandAvailable("claude"))(
|
||||
"accepts a new prompt after background sleep finishes and replies HELLO",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "claude-autonomous-followup-real" },
|
||||
});
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-autonomous-followup-real",
|
||||
...getFullAccessConfig("claude"),
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Use a background task to run exactly: sleep 5",
|
||||
"Do not wait for it to finish.",
|
||||
"After launching it, reply with exactly: SPAWNED",
|
||||
].join(" ")
|
||||
);
|
||||
const firstFinish = await client.waitForFinish(agent.id, 240_000);
|
||||
expect(firstFinish.status).toBe("idle");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 6_000));
|
||||
|
||||
await client.sendMessage(agent.id, "say exactly HELLO");
|
||||
const secondFinish = await client.waitForFinish(agent.id, 240_000);
|
||||
expect(secondFinish.status).toBe("idle");
|
||||
expect(secondFinish.lastMessage?.trim()).toBe("HELLO");
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
420_000
|
||||
);
|
||||
|
||||
test.runIf(isCommandAvailable("claude"))(
|
||||
"repro: do-it-again + immediate hello can hang after autonomous wake under churn",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const wsUrl = `ws://127.0.0.1:${daemon.port}/ws`;
|
||||
const client = new DaemonClient({ url: wsUrl });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "claude-hang-repro-real" },
|
||||
});
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-hang-repro-real",
|
||||
...getFullAccessConfig("claude"),
|
||||
});
|
||||
|
||||
for (let cycle = 0; cycle < 20; cycle += 1) {
|
||||
const noise = runPreHelloNoise({ wsUrl, durationMs: 9_000 });
|
||||
|
||||
await client.sendMessage(agent.id, "sleep for 5, in the background");
|
||||
const firstKickoff = await client.waitForFinish(agent.id, 180_000);
|
||||
expect(firstKickoff.status).toBe("idle");
|
||||
|
||||
await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000
|
||||
);
|
||||
const firstCompletion = await client.waitForFinish(agent.id, 90_000);
|
||||
expect(firstCompletion.status).toBe("idle");
|
||||
|
||||
await client.sendMessage(agent.id, "do it again");
|
||||
const secondKickoff = await client.waitForFinish(agent.id, 180_000);
|
||||
expect(secondKickoff.status).toBe("idle");
|
||||
|
||||
await client.sendMessage(agent.id, "hello");
|
||||
const helloReply = await client.waitForFinish(agent.id, 180_000);
|
||||
expect(helloReply.status).toBe("idle");
|
||||
expect((helloReply.lastMessage ?? "").toLowerCase()).toContain("hello");
|
||||
|
||||
const runningSnapshot = await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000
|
||||
);
|
||||
|
||||
const timelineAtWake = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
cycle,
|
||||
wakeUpdatedAt: runningSnapshot.updatedAt,
|
||||
entriesAtWake: timelineAtWake.entries.length,
|
||||
})
|
||||
);
|
||||
|
||||
await sleep(6_000);
|
||||
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
expect(timelineAfterWake.entries.length).toBeGreaterThan(
|
||||
timelineAtWake.entries.length
|
||||
);
|
||||
|
||||
const secondCompletion = await client.waitForFinish(agent.id, 20_000);
|
||||
expect(secondCompletion.status).toBe("idle");
|
||||
|
||||
await noise;
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
900_000
|
||||
);
|
||||
|
||||
test.runIf(isCommandAvailable("claude"))(
|
||||
"repro: second background sleep completion after HELLO should settle back to idle",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "claude-background-repro-real" },
|
||||
});
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-background-repro-real",
|
||||
...getFullAccessConfig("claude"),
|
||||
});
|
||||
|
||||
await client.sendMessage(agent.id, "sleep for 5, in the background");
|
||||
const firstKickoff = await client.waitForFinish(agent.id, 180_000);
|
||||
expect(firstKickoff.status).toBe("idle");
|
||||
|
||||
await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000
|
||||
);
|
||||
const firstCompletion = await client.waitForFinish(agent.id, 60_000);
|
||||
expect(firstCompletion.status).toBe("idle");
|
||||
|
||||
await client.sendMessage(agent.id, "do it again");
|
||||
const secondKickoff = await client.waitForFinish(agent.id, 180_000);
|
||||
expect(secondKickoff.status).toBe("idle");
|
||||
|
||||
await client.sendMessage(agent.id, "hello");
|
||||
const helloReply = await client.waitForFinish(agent.id, 180_000);
|
||||
expect(helloReply.status).toBe("idle");
|
||||
expect((helloReply.lastMessage ?? "").toLowerCase()).toContain("hello");
|
||||
|
||||
await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000
|
||||
);
|
||||
|
||||
// This is the failure point seen in production: agent flips to running
|
||||
// on task completion and never settles back to idle.
|
||||
const secondCompletion = await client.waitForFinish(agent.id, 20_000);
|
||||
expect(secondCompletion.status).toBe("idle");
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
600_000
|
||||
);
|
||||
|
||||
test.runIf(isCommandAvailable("claude"))(
|
||||
"stress: immediate HELLO before task notification should not leave autonomous run stuck",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "claude-autonomous-race-stress-real" },
|
||||
});
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-autonomous-race-stress-real",
|
||||
...getFullAccessConfig("claude"),
|
||||
});
|
||||
|
||||
for (let cycle = 0; cycle < 60; cycle += 1) {
|
||||
const helloToken = `HELLO_CYCLE_${cycle}`;
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Use the Task tool (not a Bash background process).",
|
||||
"Start a background task that runs exactly: sleep 1",
|
||||
"Do not wait for the task result.",
|
||||
"Immediately reply with exactly: SPAWNED",
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
const firstFinish = await client.waitForFinish(agent.id, 240_000);
|
||||
expect(firstFinish.status).toBe("idle");
|
||||
|
||||
// Race path under test: immediately send the next user prompt before
|
||||
// the background-task completion notification wakes Claude again.
|
||||
await client.sendMessage(agent.id, `say exactly ${helloToken}`);
|
||||
const secondFinish = await client.waitForFinish(agent.id, 240_000);
|
||||
expect(secondFinish.status).toBe("idle");
|
||||
expect(secondFinish.lastMessage?.trim()).toBe(helloToken);
|
||||
|
||||
const wakeSnapshot = await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
20_000
|
||||
);
|
||||
|
||||
const timelineAtWake = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
cycle,
|
||||
autonomousWakeUpdatedAt: wakeSnapshot.updatedAt,
|
||||
timelineEntriesAtWake: timelineAtWake.entries.length,
|
||||
})
|
||||
);
|
||||
|
||||
await sleep(3_000);
|
||||
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
expect(timelineAfterWake.entries.length).toBeGreaterThan(
|
||||
timelineAtWake.entries.length
|
||||
);
|
||||
|
||||
await client.cancelAgent(agent.id);
|
||||
const afterCancel = await client.waitForFinish(agent.id, 10_000);
|
||||
expect(afterCancel.status).toBe("idle");
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
900_000
|
||||
);
|
||||
|
||||
test.runIf(isCommandAvailable("claude"))(
|
||||
"repro: transcript/timeline parity after do-it-again + hello race (hang + interrupt + drop)",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "claude-transcript-parity-race" },
|
||||
});
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-transcript-parity-race",
|
||||
...getFullAccessConfig("claude"),
|
||||
});
|
||||
|
||||
await client.sendMessage(agent.id, "sleep for 5, in the background");
|
||||
const firstKickoff = await client.waitForFinish(agent.id, 180_000);
|
||||
expect(firstKickoff.status).toBe("idle");
|
||||
|
||||
await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000
|
||||
);
|
||||
const firstAutonomousCompletion = await client.waitForFinish(agent.id, 60_000);
|
||||
expect(firstAutonomousCompletion.status).toBe("idle");
|
||||
|
||||
await client.sendMessage(agent.id, "do it again");
|
||||
const secondKickoff = await client.waitForFinish(agent.id, 180_000);
|
||||
expect(secondKickoff.status).toBe("idle");
|
||||
|
||||
await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000
|
||||
);
|
||||
await sleep(500);
|
||||
await client.sendMessage(agent.id, "hello");
|
||||
|
||||
let waitError: Error | null = null;
|
||||
try {
|
||||
await client.waitForFinish(agent.id, 20_000);
|
||||
} catch (error) {
|
||||
waitError =
|
||||
error instanceof Error ? error : new Error(String(error ?? "wait_for_finish failed"));
|
||||
}
|
||||
|
||||
let afterWait = await client.fetchAgent(agent.id);
|
||||
let cancelRecovered = true;
|
||||
if (afterWait?.status === "running") {
|
||||
await client.cancelAgent(agent.id);
|
||||
try {
|
||||
const afterCancel = await client.waitForFinish(agent.id, 15_000);
|
||||
cancelRecovered = afterCancel.status === "idle";
|
||||
} catch {
|
||||
cancelRecovered = false;
|
||||
}
|
||||
afterWait = await client.fetchAgent(agent.id);
|
||||
}
|
||||
|
||||
expect(waitError).toBeNull();
|
||||
expect(cancelRecovered).toBe(true);
|
||||
|
||||
const sessionId =
|
||||
afterWait?.persistence?.sessionId ??
|
||||
afterWait?.runtimeInfo?.sessionId ??
|
||||
null;
|
||||
expect(typeof sessionId === "string" && sessionId.length > 0).toBe(true);
|
||||
const transcriptCwd = afterWait?.cwd ?? cwd;
|
||||
const transcriptPath = resolveClaudeTranscriptPath({
|
||||
cwd: transcriptCwd,
|
||||
sessionId: sessionId as string,
|
||||
});
|
||||
|
||||
let evidence: TranscriptRaceEvidence | null = null;
|
||||
const transcriptDeadline = Date.now() + 20_000;
|
||||
while (Date.now() < transcriptDeadline) {
|
||||
const lines = readTranscriptLines(transcriptPath);
|
||||
evidence = extractTranscriptRaceEvidence(lines);
|
||||
if (evidence) {
|
||||
break;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
if (!evidence) {
|
||||
const transcriptDump = summarizeTranscriptTail(readTranscriptLines(transcriptPath));
|
||||
throw new Error(
|
||||
[
|
||||
"Failed to extract transcript race evidence (hello assistant and notification-turn assistant).",
|
||||
`transcriptPath=${transcriptPath}`,
|
||||
`waitError=${waitError ? waitError.message : "null"}`,
|
||||
`afterWaitStatus=${afterWait?.status ?? "unknown"}`,
|
||||
`cancelRecovered=${cancelRecovered}`,
|
||||
"transcriptTail:",
|
||||
transcriptDump,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const assistantTexts = timeline.entries
|
||||
.filter(
|
||||
(
|
||||
entry
|
||||
): entry is {
|
||||
item: { type: "assistant_message"; text: string };
|
||||
} => entry.item.type === "assistant_message"
|
||||
)
|
||||
.map((entry) => normalizeText(entry.item.text));
|
||||
|
||||
const helloAssistant = normalizeText(evidence.helloAssistantText);
|
||||
const notificationAssistant = normalizeText(
|
||||
evidence.notificationOutcomeAssistantText
|
||||
);
|
||||
|
||||
expect(
|
||||
assistantTexts.some((text) => text.includes(helloAssistant))
|
||||
).toBe(true);
|
||||
expect(
|
||||
assistantTexts.some((text) => text.includes(notificationAssistant))
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
600_000
|
||||
);
|
||||
});
|
||||
@@ -291,25 +291,25 @@ export function startRelayTransport({
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const ensureClientDataSocket = (routingId: string): void => {
|
||||
const ensureClientDataSocket = (connectionId: string): void => {
|
||||
if (stopped) return;
|
||||
if (!routingId) return;
|
||||
if (dataSockets.has(routingId)) return;
|
||||
if (!connectionId) return;
|
||||
if (dataSockets.has(connectionId)) return;
|
||||
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: relayEndpoint,
|
||||
serverId,
|
||||
role: "server",
|
||||
connectionId: routingId,
|
||||
connectionId,
|
||||
});
|
||||
const socket = new WebSocket(url, { handshakeTimeout: 10_000, perMessageDeflate: false });
|
||||
dataSockets.set(routingId, socket);
|
||||
dataSockets.set(connectionId, socket);
|
||||
|
||||
let attached = false;
|
||||
const openTimeout = setTimeout(() => {
|
||||
if (stopped) return;
|
||||
if (socket.readyState === WebSocket.OPEN) return;
|
||||
relayLogger.warn({ url, routingId }, "relay_data_open_timeout_terminating");
|
||||
relayLogger.warn({ url, connectionId }, "relay_data_open_timeout_terminating");
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
@@ -319,7 +319,7 @@ export function startRelayTransport({
|
||||
|
||||
socket.on("open", () => {
|
||||
clearTimeout(openTimeout);
|
||||
relayLogger.info({ url, routingId }, "relay_data_connected");
|
||||
relayLogger.info({ url, connectionId }, "relay_data_connected");
|
||||
if (attached) return;
|
||||
attached = true;
|
||||
const externalMetadata: ExternalSocketMetadata = {
|
||||
@@ -329,7 +329,7 @@ export function startRelayTransport({
|
||||
void attachEncryptedSocket(
|
||||
socket,
|
||||
daemonKeyPair,
|
||||
relayLogger.child({ routingId }),
|
||||
relayLogger.child({ connectionId }),
|
||||
attachSocket,
|
||||
externalMetadata
|
||||
);
|
||||
@@ -341,16 +341,16 @@ export function startRelayTransport({
|
||||
socket.on("close", (code, reason) => {
|
||||
clearTimeout(openTimeout);
|
||||
relayLogger.warn(
|
||||
{ code, reason: reason?.toString?.(), url, routingId },
|
||||
{ code, reason: reason?.toString?.(), url, connectionId },
|
||||
"relay_data_disconnected"
|
||||
);
|
||||
if (dataSockets.get(routingId) === socket) {
|
||||
dataSockets.delete(routingId);
|
||||
if (dataSockets.get(connectionId) === socket) {
|
||||
dataSockets.delete(connectionId);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
relayLogger.warn({ err, url, routingId }, "relay_data_error");
|
||||
relayLogger.warn({ err, url, connectionId }, "relay_data_error");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ import type {
|
||||
ManagedAgent,
|
||||
} from './agent/agent-manager.js'
|
||||
import { scheduleAgentMetadataGeneration } from './agent/agent-metadata-generator.js'
|
||||
import { toAgentPayload } from './agent/agent-projections.js'
|
||||
import { resolveEffectiveThinkingOptionId, toAgentPayload } from './agent/agent-projections.js'
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
@@ -953,12 +953,33 @@ export class Session {
|
||||
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null
|
||||
|
||||
const provider = coerceAgentProvider(this.sessionLogger, record.provider, record.id)
|
||||
const runtimeInfo = record.runtimeInfo
|
||||
? {
|
||||
provider: coerceAgentProvider(this.sessionLogger, record.runtimeInfo.provider, record.id),
|
||||
sessionId: record.runtimeInfo.sessionId,
|
||||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, 'model')
|
||||
? { model: record.runtimeInfo.model ?? null }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, 'thinkingOptionId')
|
||||
? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, 'modeId')
|
||||
? { modeId: record.runtimeInfo.modeId ?? null }
|
||||
: {}),
|
||||
...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}),
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
id: record.id,
|
||||
provider,
|
||||
cwd: record.cwd,
|
||||
model: record.config?.model ?? null,
|
||||
thinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||||
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
|
||||
runtimeInfo,
|
||||
configuredThinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||||
}),
|
||||
...(runtimeInfo ? { runtimeInfo } : {}),
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
||||
@@ -1055,6 +1076,21 @@ export class Session {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter?.thinkingOptionId !== undefined) {
|
||||
const expectedThinkingOptionId = resolveEffectiveThinkingOptionId({
|
||||
configuredThinkingOptionId: filter.thinkingOptionId ?? null,
|
||||
})
|
||||
const resolvedThinkingOptionId =
|
||||
agent.effectiveThinkingOptionId ??
|
||||
resolveEffectiveThinkingOptionId({
|
||||
runtimeInfo: agent.runtimeInfo,
|
||||
configuredThinkingOptionId: agent.thinkingOptionId ?? null,
|
||||
})
|
||||
if (resolvedThinkingOptionId !== expectedThinkingOptionId) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (filter?.statuses && filter.statuses.length > 0) {
|
||||
const statuses = new Set(filter.statuses)
|
||||
if (!statuses.has(agent.status)) {
|
||||
|
||||
@@ -366,6 +366,7 @@ const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
sessionId: z.string().nullable(),
|
||||
model: z.string().nullable().optional(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
modeId: z.string().nullable().optional(),
|
||||
extra: z.record(z.unknown()).optional(),
|
||||
})
|
||||
@@ -376,6 +377,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
cwd: z.string(),
|
||||
model: z.string().nullable(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
effectiveThinkingOptionId: z.string().nullable().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
lastUserMessageAt: z.string().nullable(),
|
||||
@@ -426,6 +428,7 @@ const AgentDirectoryFilterSchema = z.object({
|
||||
statuses: z.array(AgentStatusSchema).optional(),
|
||||
includeArchived: z.boolean().optional(),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export const DeleteAgentRequestMessageSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user