Skip to content
Open
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
126 changes: 122 additions & 4 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2707,14 +2707,14 @@ describe("AcpAdapterV2", () => {
"cognition.ai/subagent_context": { parentAgentId: "child-a" },
},
},
{
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "ONE" },
...["O", "N", "E"].map((text) => ({
sessionUpdate: "agent_message_chunk" as const,
content: { type: "text" as const, text },
_meta: {
"cognition.ai/streamingMessageId": "child-result",
"cognition.ai/subagent_context": { parentAgentId: "child-a" },
},
},
})),
{
sessionUpdate: "tool_call",
toolCallId: "child-later",
Expand Down Expand Up @@ -2867,6 +2867,15 @@ describe("AcpAdapterV2", () => {
(item) => item.threadId === task?.childThreadId && item.type === "assistant_message",
);
assert.equal(new Set(childAnswers.map((item) => item.ordinal)).size, 2);
// Later chunks coalesce; the subagent's completion flushes the final text.
assert.deepEqual(
childAnswers.flatMap((item) =>
item.type === "assistant_message" && item.text !== "Checking the code."
? [item.text]
: [],
),
["O", "ONE"],
);
const parentTools = items.filter(
(item) => item.threadId === threadId && item.type === "dynamic_tool",
);
Expand All @@ -2875,6 +2884,115 @@ describe("AcpAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("emits coalesced subagent text before a failed turn's terminal", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"];
let handler: Parameters<Runtime["handleSessionUpdate"]>[0] | undefined;
const instanceId = ProviderInstanceId.make("devin-failed-flush");
const adapter = yield* makeAcpAdapterV2({
instanceId,
selfInvocation: yield* resolveSelfInvocation(),
flavor: {
driver: ACP_TEST_DRIVER,
capabilities: AcpProviderCapabilitiesV2,
normalizeSessionUpdate: normalizeDevinSessionUpdate,
normalizeToolCall: normalizeDevinToolCall,
extractSubagentUpdate: extractDevinSubagentUpdate,
makeRuntime: makeMockRuntime({
childProcessSpawner,
mockAgentPath,
wrapRuntime: (runtime) => ({
...runtime,
handleSessionUpdate: (next) =>
Effect.sync(() => {
handler = next;
}).pipe(Effect.andThen(runtime.handleSessionUpdate(next))),
prompt: () =>
Effect.gen(function* () {
const updates = [
{
sessionUpdate: "tool_call_update",
toolCallId: "child-a",
status: "in_progress",
_meta: {
"cognition.ai/subagent_started": {
agentId: "child-a",
title: "Answer",
task: "Reply ONE.",
},
},
},
...["O", "N", "E"].map((text) => ({
sessionUpdate: "agent_message_chunk" as const,
content: { type: "text" as const, text },
_meta: {
"cognition.ai/streamingMessageId": "child-result",
"cognition.ai/subagent_context": { parentAgentId: "child-a" },
},
})),
] satisfies Array<EffectAcpSchema.SessionUpdate>;
for (const update of updates)
yield* handler!({ sessionId: "mock-session-1", update });
return yield* new EffectAcpErrors.AcpRequestError({
code: -32603,
errorMessage: "prompt failed",
});
}),
}),
}),
},
});
const threadId = ThreadId.make("devin-failed-flush-parent");
const modelSelection = { instanceId, model: "default" };
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: process.cwd(),
});
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("devin-failed-flush-session"),
modelSelection,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
yield* runtime.startTurn(
makeTurnInput({
threadId,
providerThread,
instanceId,
runtimePolicy,
now: yield* DateTime.now,
}),
);
const events = Array.from(
yield* runtime.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runCollect,
),
);
const terminal = events.at(-1);
assert.equal(terminal?.type === "turn.terminal" ? terminal.status : undefined, "failed");
const childText = events.flatMap((event) =>
event.type === "turn_item.updated" &&
event.turnItem.type === "assistant_message" &&
event.turnItem.threadId !== threadId
? [event.turnItem.text]
: [],
);
assert.equal(childText.at(-1), "ONE");
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("routes interleaved Copilot subagent output into subagent threads", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1982,6 +1982,42 @@ describe("AcpRegistryAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("writes a child's coalesced text before a restart resets it", () =>
Effect.gen(function* () {
const { update, childState, idle, prompt } = claudeFrames;
const reply = (text: string) =>
update("child-1", {
sessionUpdate: "agent_message_chunk",
messageId: "reply",
content: { type: "text", text },
});
const session = yield* openClaudeReplaySession([
...claudeFrames.open,
...prompt(1),
...claudeChild.slice(0, -1),
reply("Review "),
reply("done."),
// A working grandchild keeps the idle child from publishing (and flushing) its status.
childState("child-1", "grandchild", "running"),
childState(CLAUDE_ROOT_SESSION, "child-1", "idle"),
childState(CLAUDE_ROOT_SESSION, "child-1", "running"),
idle("end_turn"),
claudeFrames.closeRequest,
claudeFrames.closed,
]);
yield* session.runtime.startTurn(session.turnInput(1));
const events = yield* session.until((event) => event.type === "turn.terminal");
assert.isTrue(
events.some(
(event) =>
event.type === "message.updated" &&
event.message.role === "assistant" &&
event.message.text === "Review done.",
),
);
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("fails active Claude work and releases its children when the process exits", () =>
Effect.gen(function* () {
const session = yield* openClaudeReplaySession([
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/orchestration-v2/messageSearchIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const nonPlaceholderMessageCondition = (sql: SqlClient.SqlClient, row = "

/**
* Exclude attachment placeholders, generated notices and subagent threads. ACP subagents
* rewrite their finished answer on every streamed chunk.
* rewrite their finished answer while it streams.
* Migration triggers, backfill, rebuild and scans share this predicate; `row` may be `new`.
*/
export const indexedMessageCondition = (sql: SqlClient.SqlClient, row = "messages") =>
Expand Down
77 changes: 68 additions & 9 deletions packages/provider-acp/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ const ACP_DEFERRED_FINALIZE_DEBOUNCE: Duration.Input = "3000 millis";
* This avoids quadratic output; scheduled flushes can run late under host load.
*/
const ACP_TEXT_FLUSH_INTERVAL: Duration.Input = "50 millis";
// Subagent messages are stored as finished, so they skip the server's paragraph
// throttle and every snapshot is persisted. Match that throttle's 400 ms cadence.
const ACP_SUBAGENT_TEXT_FLUSH_INTERVAL: Duration.Input = "400 millis";

export interface AcpAdapterV2RuntimeInput {
readonly cwd: string;
Expand Down Expand Up @@ -1403,6 +1406,8 @@ interface ActiveAcpSubagent {
childSessionId: string | null;
assistantText: string;
readonly assistantMessages: Map<string, string>;
// Messages whose latest text waits for a delayed flush.
readonly pendingAssistantMessages: Set<string>;
readonly childItemOrdinals: Map<string, number>;
nextChildOrdinal: number;
terminalStatusProjected: boolean;
Expand Down Expand Up @@ -2712,10 +2717,53 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
) {
if (text.length === 0 && mode === "append") return;
const nativeItemId = `${subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id}:message:${messageId ?? "result"}`;
const previous = subagent.assistantMessages.get(nativeItemId) ?? "";
const messageText = mode === "replace" ? text : `${previous}${text}`;
const previous = subagent.assistantMessages.get(nativeItemId);
const messageText = mode === "replace" ? text : `${previous ?? ""}${text}`;
subagent.assistantMessages.set(nativeItemId, messageText);
subagent.assistantText = messageText;
if (mode === "append" && previous !== undefined) {
if (subagent.pendingAssistantMessages.has(nativeItemId)) return;
subagent.pendingAssistantMessages.add(nativeItemId);
yield* writeSubagentAssistant(subagent, nativeItemId, true).pipe(
Effect.delay(ACP_SUBAGENT_TEXT_FLUSH_INTERVAL),
Effect.forkIn(sessionScope),
);
return;
}
subagent.pendingAssistantMessages.delete(nativeItemId);
yield* writeSubagentAssistant(subagent, nativeItemId, false);
});

// Reads the pending set under the permit, so it also waits for a write in progress.
const flushPendingSubagentAssistant = (subagent: ActiveAcpSubagent) =>
Effect.suspend(() =>
Effect.forEach(
[...subagent.pendingAssistantMessages],
(nativeItemId) => writeSubagentAssistantUnlocked(subagent, nativeItemId, true),
{ discard: true },
),
);

const flushSubagentAssistant = (subagent: ActiveAcpSubagent) =>
textEmissionPermit.withPermit(flushPendingSubagentAssistant(subagent));

const writeSubagentAssistant = (
subagent: ActiveAcpSubagent,
nativeItemId: string,
onlyIfPending: boolean,
) =>
textEmissionPermit.withPermit(
writeSubagentAssistantUnlocked(subagent, nativeItemId, onlyIfPending),
);

const writeSubagentAssistantUnlocked = Effect.fnUntraced(function* (
subagent: ActiveAcpSubagent,
nativeItemId: string,
onlyIfPending: boolean,
) {
if (onlyIfPending && !subagent.pendingAssistantMessages.delete(nativeItemId)) return;
const text = subagent.assistantMessages.get(nativeItemId);
if (text === undefined) return;
const now = yield* DateTime.now;
const ordinal = resolveSubagentChildOrdinal(subagent, nativeItemId);
const artifacts = makeSubagentConversationArtifacts({
Expand All @@ -2727,7 +2775,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
providerTurnId: null,
nativeItemRef: { driver, nativeId: nativeItemId, strength: "weak" },
role: "assistant",
text: subagent.assistantText,
text,
ordinal,
now,
});
Expand Down Expand Up @@ -2945,6 +2993,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
childSessionId: null,
assistantText: "",
assistantMessages: new Map(),
pendingAssistantMessages: new Set(),
childItemOrdinals: new Map(),
nextChildOrdinal: 101,
terminalStatusProjected: false,
Expand Down Expand Up @@ -3046,6 +3095,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
);
}

yield* flushSubagentAssistant(subagent);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (
taskStatus !== "running" &&
subagent.assistantText.length === 0 &&
Expand Down Expand Up @@ -3156,20 +3206,22 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
return child;
};

const setNativeChildState = (
const setNativeChildState = Effect.fnUntraced(function* (
child: AcpNativeChildSession,
status: AcpNativeChildSession["status"],
result: string | null,
) => {
) {
if (child.status !== "running" && status === "running" && child.subagent !== undefined) {
yield* flushSubagentAssistant(child.subagent);
// A new execution replaces its result; live ownership transfers keep their progress.
child.subagent.task = { ...child.subagent.task, result: null };
child.subagent.assistantText = "";
child.subagent.assistantMessages.clear();
child.subagent.pendingAssistantMessages.clear();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
child.status = status;
child.result = result;
};
});

const nativeChildOwner = (sessionId: string): AcpNativeChildSession | undefined => {
const visited = new Set<string>();
Expand Down Expand Up @@ -3244,7 +3296,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
child.delegationId !== subagent.task.nativeTaskRef?.nativeId &&
!subagent.childItemOrdinals.has(`${sessionId}:prompt:${child.delegationId}`);
if (newDelegation && child.status !== "running")
setNativeChildState(child, "running", null);
yield* setNativeChildState(child, "running", null);
const hasNativeWork = child.status === "running" || hasWorkingDescendant(sessionId);
if (
(hasNativeWork && acpSubagentStatusIsTerminal(subagent.task.status)) ||
Expand Down Expand Up @@ -3381,7 +3433,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
const child = nativeChild(event.childSessionId);
child.parentSessionId = event.parentSessionId ?? child.parentSessionId;
if (event.type === "state") {
setNativeChildState(child, event.status, event.result);
yield* setNativeChildState(child, event.status, event.result);
} else {
child.delegationId = event.delegationId;
child.prompt = event.prompt;
Expand All @@ -3408,7 +3460,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
const update = notification.update;
if (update.sessionUpdate === "state_update") {
const child = nativeChild(notification.sessionId);
setNativeChildState(
yield* setNativeChildState(
child,
update.state !== "idle"
? "running"
Expand Down Expand Up @@ -5673,6 +5725,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
status: OrchestrationV2Subagent["status"],
resultOverride?: string | null,
) {
yield* flushSubagentAssistant(subagent);
yield* mutateCarryoverSubagentStatus(subagent, status, resultOverride);
const now = subagent.task.updatedAt;
const nativeTaskId = subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id;
Expand Down Expand Up @@ -7255,6 +7308,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
});
}
yield* closeTextStreams(context);
// Ingestion may stop at turn.terminal, before a delayed flush would run.
yield* textEmissionPermit.withPermit(
Effect.forEach(context.subagents.values(), flushPendingSubagentAssistant, {
discard: true,
}),
);
if (settledStatus !== "failed") {
yield* emitProviderRetry(context, settledStatus);
}
Expand Down
Loading