Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4610,6 +4610,62 @@ describe("createAgentChatService", () => {
});
});

// ADE-122 regression: seeding a fork re-published every historical envelope
// to live event subscribers, streaming the entire source chat over IPC/sync
// and stalling the app during the handoff. Seeded history must be durable
// and readable, but never live-published.
it("seeds forked history into storage without re-publishing it as live events", async () => {
installRealTranscriptParser();
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const source = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
modelId: "openai/gpt-5.5",
});
source.threadId = "source-thread-live-publish";
const sourceEnvelopes: AgentChatEventEnvelope[] = Array.from({ length: 4 }, (_, index) => ({
sessionId: source.id,
timestamp: `2026-07-10T11:0${index}:00.000Z`,
event: index % 2 === 0
? { type: "user_message", messageId: `seed-user-${index}`, text: `Seed user message ${index}.` }
: { type: "text", messageId: `seed-assistant-${index}`, text: `Seed assistant reply ${index}.` },
provenance: { messageId: `provider-message-${index}` },
}));
writeTestTranscriptEnvelopes(source.id, sourceEnvelopes);
mockState.codexResponseOverrides.set("thread/fork", { thread: { id: "forked-thread-live-publish" } });

const result = await service.handoffSession({
sourceSessionId: source.id,
targetModelId: "openai/gpt-5.5",
mode: "fork",
});

const publishedSeeded = events.filter((envelope) =>
envelope.sessionId === result.session.id
&& (envelope.provenance as { providerOrigin?: string } | undefined)?.providerOrigin === "handoff_fork");
expect(publishedSeeded).toHaveLength(0);

const history = service.getChatEventHistory(result.session.id, { maxEvents: 50 });
const seededHistory = history.events.filter((envelope) =>
(envelope.provenance as { providerOrigin?: string } | undefined)?.providerOrigin === "handoff_fork");
expect(seededHistory).toHaveLength(sourceEnvelopes.length);

const targetTranscript = path.join(tmpRoot, ".ade", "transcripts", "chat", `${result.session.id}.jsonl`);
await vi.waitFor(() => {
const parsed = fs.readFileSync(targetTranscript, "utf8")
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line) as AgentChatEventEnvelope);
const seededLines = parsed.filter((envelope) =>
(envelope.provenance as { providerOrigin?: string } | undefined)?.providerOrigin === "handoff_fork");
expect(seededLines).toHaveLength(sourceEnvelopes.length);
});
});

it("forks an OpenCode chat from the source session without injecting a summary prompt", async () => {
vi.mocked(streamText).mockReturnValue({
fullStream: (async function* () {
Expand Down
135 changes: 86 additions & 49 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10934,24 +10934,29 @@ export function createAgentChatService(args: {
}
};

const writeTranscript = (managed: ManagedChatSession, envelope: AgentChatEventEnvelope): void => {
const writeTranscript = (
managed: ManagedChatSession,
envelope: AgentChatEventEnvelope,
options?: { deferFlush?: boolean },
): void => {
// The legacy transcript_path is byte-capped because it also backs
// terminal-session storage. The dedicated chat transcript is the durable
// replay source and must keep receiving events after that cap is reached.
writeChatTranscriptLine(managed.session.id, envelope);
writeChatTranscriptLine(managed.session.id, envelope, options);
if (managed.transcriptLimitReached) return;
try {
fs.mkdirSync(path.dirname(managed.transcriptPath), { recursive: true });
const warnTailHealed = (details: { path: string; priorSize: number }) => {
logger.warn("agent_chat.transcript_tail_healed", details);
};
const flushOnUserMessage = envelope.event.type === "user_message" && !options?.deferFlush;
const rawLine = `${JSON.stringify(envelope)}\n`;
const chunk = Buffer.from(rawLine, "utf8");
const remaining = MAX_CHAT_TRANSCRIPT_BYTES - managed.transcriptBytesWritten;
if (remaining <= 0) {
managed.transcriptLimitReached = true;
queueTranscriptWrite(managed.transcriptPath, CHAT_TRANSCRIPT_LIMIT_NOTICE, warnTailHealed);
if (envelope.event.type === "user_message") flushQueuedTranscriptWrite(managed.transcriptPath);
if (flushOnUserMessage) flushQueuedTranscriptWrite(managed.transcriptPath);
return;
}
let toWrite = chunk;
Expand All @@ -10964,20 +10969,26 @@ export function createAgentChatService(args: {
if (managed.transcriptLimitReached) {
queueTranscriptWrite(managed.transcriptPath, CHAT_TRANSCRIPT_LIMIT_NOTICE, warnTailHealed);
}
if (envelope.event.type === "user_message") flushQueuedTranscriptWrite(managed.transcriptPath);
if (flushOnUserMessage) flushQueuedTranscriptWrite(managed.transcriptPath);
} catch {
// ignore transcript write failures
}
};

const writeChatTranscriptLine = (sessionId: string, envelope: AgentChatEventEnvelope): void => {
const writeChatTranscriptLine = (
sessionId: string,
envelope: AgentChatEventEnvelope,
options?: { deferFlush?: boolean },
): void => {
try {
const transcriptFile = path.join(chatTranscriptsDir, `${sessionId}.jsonl`);
const line = `${JSON.stringify(envelope)}\n`;
queueTranscriptWrite(transcriptFile, line, (details) => {
logger.warn("agent_chat.transcript_tail_healed", details);
});
if (envelope.event.type === "user_message") flushQueuedTranscriptWrite(transcriptFile);
if (envelope.event.type === "user_message" && !options?.deferFlush) {
flushQueuedTranscriptWrite(transcriptFile);
}
} catch {
// ignore chat transcript write failures
}
Expand Down Expand Up @@ -12982,50 +12993,76 @@ export function createAgentChatService(args: {
}
};

const appendImportedChatEvents = (
// Seeding a fork or import can carry thousands of historical envelopes;
// chunked processing with event-loop yields keeps the shared runtime
// responsive while they persist (ADE-122: fork handoff froze the app).
const IMPORTED_CHAT_EVENT_CHUNK_SIZE = 250;

const appendImportedChatEvents = async (
managed: ManagedChatSession,
envelopes: AgentChatEventEnvelope[],
): void => {
): Promise<void> => {
flushBufferedReasoning(managed);
flushBufferedText(managed);

for (const envelope of envelopes) {
const liveEvent = envelope.event;
const storedEvent = compactChatEventForStorage(liveEvent);
trackSubagentEvent(managed, liveEvent);
appendRecentConversationEntry(managed, liveEvent);

if (storedEvent.type === "text") {
updatePreviewFromText(managed, storedEvent);
} else if (storedEvent.type === "command") {
setSessionPreview(managed, storedEvent.output);
} else if (storedEvent.type === "error") {
setSessionPreview(managed, storedEvent.message);
} else if (storedEvent.type === "completion_report") {
managed.session.completion = storedEvent.report;
if (storedEvent.report.summary.trim().length > 0) {
setSessionPreview(managed, storedEvent.report.summary);
}
}

const timestamp = envelope.timestamp || nowIso();
const sequence = ++managed.eventSequence;
const storedEnvelope: AgentChatEventEnvelope = {
...envelope,
sessionId: managed.session.id,
timestamp,
event: storedEvent,
sequence,
};
const liveEnvelope: AgentChatEventEnvelope = liveEvent === storedEvent
? storedEnvelope
: {
...storedEnvelope,
event: liveEvent,
};
writeTranscript(managed, storedEnvelope);
recordChatEventInHistory(storedEnvelope);
publishChatEnvelope(liveEnvelope);
// Imported envelopes are historical: no client can be viewing a session
// whose history is still being seeded, and every reader loads seeded
// events through the history/transcript APIs. They are therefore written
// and recorded but NOT published to live event subscribers — publishing
// re-streams the entire source chat over IPC/sync for nothing.
const storedEnvelopes: AgentChatEventEnvelope[] = [];
const chatTranscriptFile = path.join(chatTranscriptsDir, `${managed.session.id}.jsonl`);
for (let start = 0; start < envelopes.length; start += IMPORTED_CHAT_EVENT_CHUNK_SIZE) {
const chunk = envelopes.slice(start, start + IMPORTED_CHAT_EVENT_CHUNK_SIZE);
for (const envelope of chunk) {
const liveEvent = envelope.event;
const storedEvent = compactChatEventForStorage(liveEvent);
trackSubagentEvent(managed, liveEvent);
appendRecentConversationEntry(managed, liveEvent);

if (storedEvent.type === "text") {
updatePreviewFromText(managed, storedEvent);
} else if (storedEvent.type === "command") {
setSessionPreview(managed, storedEvent.output);
} else if (storedEvent.type === "error") {
setSessionPreview(managed, storedEvent.message);
} else if (storedEvent.type === "completion_report") {
managed.session.completion = storedEvent.report;
if (storedEvent.report.summary.trim().length > 0) {
setSessionPreview(managed, storedEvent.report.summary);
}
}

const timestamp = envelope.timestamp || nowIso();
const sequence = ++managed.eventSequence;
const storedEnvelope: AgentChatEventEnvelope = {
...envelope,
sessionId: managed.session.id,
timestamp,
event: storedEvent,
sequence,
};
writeTranscript(managed, storedEnvelope, { deferFlush: true });
storedEnvelopes.push(storedEnvelope);
}
// One append syscall per file per chunk (instead of one forced flush per
// seeded user message) bounds queued-write memory without the sync-write
// storm that stalled the runtime.
flushQueuedTranscriptWrite(chatTranscriptFile);
flushQueuedTranscriptWrite(managed.transcriptPath);
if (start + IMPORTED_CHAT_EVENT_CHUNK_SIZE < envelopes.length) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
}

if (storedEnvelopes.length) {
// Single bulk ring update; the per-envelope recordChatEventInHistory
// re-bounds the whole buffer each call (O(n²) across a large import).
const current = eventHistoryBySession.get(managed.session.id) ?? [];
eventHistoryBySession.set(
managed.session.id,
boundRingEnvelopes([...current, ...storedEnvelopes]),
);
}

managed.session.lastActivityAt = nowIso();
Expand Down Expand Up @@ -26892,7 +26929,7 @@ export function createAgentChatService(args: {
sourceSessionId: sourceId,
},
}));
if (sourceEnvelopes.length) appendImportedChatEvents(createdManaged, sourceEnvelopes);
if (sourceEnvelopes.length) await appendImportedChatEvents(createdManaged, sourceEnvelopes);
}

if (handoffMode === "brief" || handoffNote) {
Expand Down Expand Up @@ -28149,7 +28186,7 @@ export function createAgentChatService(args: {
managed: destinationManaged,
});
const importedEnvelopes = parseCrossMachineTranscriptEnvelopes(capsule, session.id);
if (importedEnvelopes.length) appendImportedChatEvents(destinationManaged, importedEnvelopes);
if (importedEnvelopes.length) await appendImportedChatEvents(destinationManaged, importedEnvelopes);
persistChatState(destinationManaged);
record = { ...record, forkMaterializedAt: nowIso(), updatedAt: nowIso() };
persistCrossMachineHandoffRecord(record);
Expand Down Expand Up @@ -28507,7 +28544,7 @@ export function createAgentChatService(args: {
});
applyImportedChatMetadata(managed, args, events, importedAt);
persistChatState(managed);
appendImportedChatEvents(managed, events);
await appendImportedChatEvents(managed, events);
persistChatState(managed);
return persistedImportedChatResult(managed, "claude");
} catch (error) {
Expand Down Expand Up @@ -28656,7 +28693,7 @@ export function createAgentChatService(args: {
});
applyImportedChatMetadata(managed, args, events, importedAt);
persistChatState(managed);
appendImportedChatEvents(managed, events);
await appendImportedChatEvents(managed, events);
persistChatState(managed);
return persistedImportedChatResult(managed, "codex");
} catch (error) {
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,28 @@ describe("ipcInvokeTimeoutMs", () => {
}])).toBe(2 * 60_000);
});

// ADE-122 regression: a handoff (AI brief + session creation + first-message
// dispatch, or cross-machine history packaging) got the 30s default on the
// direct-IPC and remote-runtime paths, fired a false timeout, and then
// completed anyway as a "surprise" session about a minute later.
it("extends handoff timeouts on direct, local runtime, and remote runtime paths", () => {
expect(ipcInvokeTimeoutMs(IPC.agentChatHandoff)).toBe(150_000);
expect(ipcInvokeTimeoutMs(IPC.agentChatPrepareCrossMachineHandoff)).toBe(150_000);
expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{
id: "target-1",
projectId: "project-1",
request: { domain: "chat", action: "handoffSession", args: {} },
}])).toBe(150_000);
expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{
id: "target-1",
projectId: "project-1",
request: { domain: "chat", action: "prepareCrossMachineHandoff", args: {} },
}])).toBe(150_000);
expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{
request: { domain: "chat", action: "handoffSession", args: {} },
}])).toBe(150_000);
});

it("extends lane creation timeouts on direct and local runtime paths", () => {
expect(ipcInvokeTimeoutMs(IPC.lanesCreate)).toBe(4 * 60_000);
expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/main/services/ipc/ipcTimeouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const RUNTIME_ACTION_CHANNEL: Record<string, Record<string, string>> = {
ensurePreviewWorkspace: IPC.iosSimulatorEnsurePreviewWorkspace,
renderCurrentPreview: IPC.iosSimulatorRenderCurrentPreview,
},
chat: {
handoffSession: IPC.agentChatHandoff,
prepareCrossMachineHandoff: IPC.agentChatPrepareCrossMachineHandoff,
},
};

const LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS = 150_000;
Expand Down Expand Up @@ -82,6 +86,13 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [
case IPC.lanesImportBranch:
case IPC.lanesDelete:
return 4 * 60_000;
// Handoff runs an AI brief + session creation + first-message dispatch
// (and cross-machine prepare additionally packages provider history);
// it must outlive the daemon-side 120s action timeout so the false
// "timed out but later succeeded" failure can't reappear via this layer.
case IPC.agentChatHandoff:
case IPC.agentChatPrepareCrossMachineHandoff:
return 150_000;
case IPC.iosSimulatorLaunch:
return 10 * 60_000;
case IPC.transcriptionTranscribe:
Expand Down
Loading
Loading