| ... | ... | @@ -998,6 +998,184 @@ describe("AcpAdapterV2", () => { |
| 998 | 998 | }).pipe(Effect.provide(layerTest), Effect.scoped), |
| 999 | 999 | ); |
| 1000 | 1000 | |
| 1001 | it.effect("persists a bounded subset of streamed root and child tool updates", () => |
| 1002 | Effect.gen(function* () { |
| 1003 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 1004 | const path = yield* Path.Path; |
| 1005 | const mockAgentPath = yield* path.fromFileUrl( |
| 1006 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 1007 | ); |
| 1008 | type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1009 | let handler: Parameters<Runtime["handleSessionUpdate"]>[0] | undefined; |
| 1010 | // Devin streams a file write as it generates it: each update resends the |
| 1011 | // whole file so far, a few characters longer, with no status. |
| 1012 | const file = `${"x = 1\n".repeat(800)}END_OF_FILE\n`; |
| 1013 | const chunks = 200; |
| 1014 | const streamedWrite = ( |
| 1015 | toolCallId: string, |
| 1016 | parentAgentId?: string, |
| 1017 | ): Array<EffectAcpSchema.SessionUpdate> => { |
| 1018 | const meta = |
| 1019 | parentAgentId === undefined ? {} : { "cognition.ai/subagent_context": { parentAgentId } }; |
| 1020 | const write = (text: string, status?: "completed") => ({ |
| 1021 | sessionUpdate: "tool_call_update" as const, |
| 1022 | toolCallId, |
| 1023 | title: "Writing ./audit.py", |
| 1024 | ...(status === undefined ? {} : { status }), |
| 1025 | content: [ |
| 1026 | { type: "diff" as const, path: "/repo/audit.py", oldText: null, newText: text }, |
| 1027 | ], |
| 1028 | rawInput: { file_path: "/repo/audit.py", content: text }, |
| 1029 | _meta: meta, |
| 1030 | }); |
| 1031 | return [ |
| 1032 | { |
| 1033 | sessionUpdate: "tool_call", |
| 1034 | toolCallId, |
| 1035 | title: "Writing …", |
| 1036 | kind: "edit", |
| 1037 | status: "pending", |
| 1038 | _meta: meta, |
| 1039 | }, |
| 1040 | ...Array.from({ length: chunks }, (_, index) => |
| 1041 | write(file.slice(0, Math.ceil((file.length * (index + 1)) / chunks))), |
| 1042 | ), |
| 1043 | write(file, "completed"), |
| 1044 | ]; |
| 1045 | }; |
| 1046 | const instanceId = ProviderInstanceId.make("devin-streamed-write"); |
| 1047 | const adapter = makeAcpAdapterV2({ |
| 1048 | instanceId, |
| 1049 | crypto: yield* Crypto.Crypto, |
| 1050 | fileSystem: yield* FileSystem.FileSystem, |
| 1051 | idAllocator: yield* IdAllocator.IdAllocatorV2, |
| 1052 | serverConfig: yield* ServerConfig.ServerConfig, |
| 1053 | selfInvocation: yield* resolveSelfInvocation(), |
| 1054 | flavor: { |
| 1055 | driver: ACP_TEST_DRIVER, |
| 1056 | capabilities: AcpProviderCapabilitiesV2, |
| 1057 | normalizeSessionUpdate: normalizeDevinSessionUpdate, |
| 1058 | normalizeToolCall: normalizeDevinToolCall, |
| 1059 | extractSubagentUpdate: extractDevinSubagentUpdate, |
| 1060 | makeRuntime: makeMockRuntime({ |
| 1061 | childProcessSpawner, |
| 1062 | mockAgentPath, |
| 1063 | wrapRuntime: (runtime) => ({ |
| 1064 | ...runtime, |
| 1065 | handleSessionUpdate: (next) => |
| 1066 | Effect.sync(() => { |
| 1067 | handler = next; |
| 1068 | }).pipe(Effect.andThen(runtime.handleSessionUpdate(next))), |
| 1069 | prompt: () => |
| 1070 | Effect.gen(function* () { |
| 1071 | const updates = [ |
| 1072 | { |
| 1073 | sessionUpdate: "tool_call_update", |
| 1074 | toolCallId: "child-a", |
| 1075 | status: "in_progress", |
| 1076 | _meta: { |
| 1077 | "cognition.ai/subagent_started": { |
| 1078 | agentId: "child-a", |
| 1079 | title: "Write audit script", |
| 1080 | task: "Write audit.py.", |
| 1081 | }, |
| 1082 | }, |
| 1083 | }, |
| 1084 | ...streamedWrite("root-write"), |
| 1085 | ...streamedWrite("child-write", "child-a"), |
| 1086 | // Same-length output replacements are still live output. |
| 1087 | ...Array.from({ length: 5 }, (_, index) => ({ |
| 1088 | sessionUpdate: "tool_call_update" as const, |
| 1089 | toolCallId: "ticker", |
| 1090 | title: "Watch ticks", |
| 1091 | kind: "execute" as const, |
| 1092 | status: "in_progress" as const, |
| 1093 | rawOutput: `tick ${index + 1}`, |
| 1094 | })), |
| 1095 | ...Array.from({ length: 5 }, (_, index) => ({ |
| 1096 | sessionUpdate: "tool_call_update" as const, |
| 1097 | toolCallId: "text-ticker", |
| 1098 | title: "Watch ticks", |
| 1099 | kind: "execute" as const, |
| 1100 | status: "in_progress" as const, |
| 1101 | rawOutput: { type: "Text", text: `tock ${index + 1}` }, |
| 1102 | })), |
| 1103 | { |
| 1104 | sessionUpdate: "tool_call_update", |
| 1105 | toolCallId: "child-a", |
| 1106 | status: "completed", |
| 1107 | _meta: { |
| 1108 | "cognition.ai/subagent_completed": { |
| 1109 | agentId: "child-a", |
| 1110 | success: true, |
| 1111 | summary: "Wrote audit.py", |
| 1112 | }, |
| 1113 | }, |
| 1114 | }, |
| 1115 | ] satisfies Array<EffectAcpSchema.SessionUpdate>; |
| 1116 | for (const update of updates) |
| 1117 | yield* handler!({ sessionId: "mock-session-1", update }); |
| 1118 | return { stopReason: "end_turn" as const }; |
| 1119 | }), |
| 1120 | }), |
| 1121 | }), |
| 1122 | }, |
| 1123 | }); |
| 1124 | const threadId = ThreadId.make("devin-streamed-write"); |
| 1125 | const modelSelection = { instanceId, model: "default" }; |
| 1126 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ |
| 1127 | runtimeMode: "full-access", |
| 1128 | interactionMode: "default", |
| 1129 | cwd: process.cwd(), |
| 1130 | }); |
| 1131 | const runtime = yield* adapter.openSession({ |
| 1132 | threadId, |
| 1133 | providerSessionId: ProviderSessionId.make("devin-streamed-write-session"), |
| 1134 | modelSelection, |
| 1135 | runtimePolicy, |
| 1136 | }); |
| 1137 | const providerThread = yield* runtime.ensureThread({ |
| 1138 | threadId, |
| 1139 | modelSelection, |
| 1140 | runtimePolicy, |
| 1141 | }); |
| 1142 | yield* runtime.startTurn( |
| 1143 | makeTurnInput({ |
| 1144 | threadId, |
| 1145 | providerThread, |
| 1146 | instanceId, |
| 1147 | runtimePolicy, |
| 1148 | now: yield* DateTime.now, |
| 1149 | }), |
| 1150 | ); |
| 1151 | const items = Array.from( |
| 1152 | yield* runtime.events.pipe( |
| 1153 | Stream.takeUntil((event) => event.type === "turn.terminal"), |
| 1154 | Stream.runCollect, |
| 1155 | ), |
| 1156 | ).flatMap((event) => (event.type === "turn_item.updated" ? [event.turnItem] : [])); |
| 1157 | for (const toolCallId of ["root-write", "child-write"]) { |
| 1158 | const writes = items.filter((item) => item.nativeItemRef?.nativeId?.endsWith(toolCallId)); |
| 1159 | assert.isAtLeast(writes.length, chunks / 10, toolCallId); |
| 1160 | assert.isAtMost(writes.length, chunks / 5, toolCallId); |
| 1161 | assert.equal(writes.at(-1)?.status, "completed", toolCallId); |
| 1162 | assert.include(JSON.stringify(writes.at(-1)), "END_OF_FILE", toolCallId); |
| 1163 | } |
| 1164 | for (const [toolCallId, word] of [ |
| 1165 | ["ticker", "tick"], |
| 1166 | ["text-ticker", "tock"], |
| 1167 | ] as const) { |
| 1168 | const ticks = items.filter((item) => item.nativeItemRef?.nativeId?.endsWith(toolCallId)); |
| 1169 | for (let tick = 1; tick <= 5; tick++) { |
| 1170 | assert.isTrue( |
| 1171 | ticks.some((item) => JSON.stringify(item).includes(`${word} ${tick}`)), |
| 1172 | `${word} ${tick} must persist`, |
| 1173 | ); |
| 1174 | } |
| 1175 | } |
| 1176 | }).pipe(Effect.provide(layerTest), Effect.scoped), |
| 1177 | ); |
| 1178 | |
| 1001 | 1179 | it.effect("keeps Devin parent paragraphs intact while projecting native child work", () => |
| 1002 | 1180 | Effect.gen(function* () { |
| 1003 | 1181 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |