| author | |
| committer | |
| log | 0bfb6df34b26dfe0162db6c09dca00bc8c5a5ec4 |
| tree | 236fa71c14a1d3b7867896604851858ea05c506c |
| parent | a9ffb8279614df6ae2f1f4b7f09a0dc42edef797 |
| signature | Signed by PGP key B5690EEEBB952194 |
19 files changed, 913 insertions(+), 98 deletions(-)
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+23| ... | ... | @@ -973,6 +973,29 @@ describe("ProviderRuntimeIngestion", () => { |
| 973 | 973 | ); |
| 974 | 974 | }); |
| 975 | 975 | |
| 976 | it("ignores provider content deltas that cannot change thread state", async () => { | |
| 977 | const harness = await createHarness(); | |
| 978 | const initial = await harness.readModel(); | |
| 979 | ||
| 980 | for (const streamKind of ["reasoning_text", "command_output", "file_change_output"] as const) { | |
| 981 | harness.emit({ | |
| 982 | type: "content.delta", | |
| 983 | eventId: asEventId(`evt-ignored-${streamKind}`), | |
| 984 | provider: ProviderDriverKind.make("codex"), | |
| 985 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 986 | threadId: asThreadId("thread-1"), | |
| 987 | turnId: asTurnId("turn-ignored"), | |
| 988 | payload: { | |
| 989 | streamKind, | |
| 990 | delta: "ignored output", | |
| 991 | }, | |
| 992 | }); | |
| 993 | } | |
| 994 | ||
| 995 | await harness.drain(); | |
| 996 | expect(await harness.readModel()).toEqual(initial); | |
| 997 | }); | |
| 998 | ||
| 976 | 999 | it("maps canonical content delta/item completed into finalized assistant messages", async () => { |
| 977 | 1000 | const harness = await createHarness(); |
| 978 | 1001 | const now = "2026-01-01T00:00:00.000Z"; |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+15-3| ... | ... | @@ -1499,6 +1499,10 @@ const make = Effect.gen(function* () { |
| 1499 | 1499 | |
| 1500 | 1500 | const processRuntimeEvent = (event: ProviderRuntimeEvent) => |
| 1501 | 1501 | Effect.gen(function* () { |
| 1502 | if (event.type === "content.delta" && event.payload.streamKind !== "assistant_text") { | |
| 1503 | return; | |
| 1504 | } | |
| 1505 | ||
| 1502 | 1506 | const thread = yield* resolveThreadShell(event.threadId); |
| 1503 | 1507 | if (!thread) return; |
| 1504 | 1508 | |
| ... | ... | @@ -1515,9 +1519,17 @@ const make = Effect.gen(function* () { |
| 1515 | 1519 | const now = event.createdAt; |
| 1516 | 1520 | const eventTurnId = toTurnId(event.turnId); |
| 1517 | 1521 | const activeTurnId = thread.session?.activeTurnId ?? null; |
| 1518 | const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ | |
| 1519 | threadId: thread.id, | |
| 1520 | }); | |
| 1522 | const pendingTurnStart = | |
| 1523 | event.type === "session.started" || | |
| 1524 | event.type === "session.state.changed" || | |
| 1525 | event.type === "session.exited" || | |
| 1526 | event.type === "thread.started" || | |
| 1527 | event.type === "turn.started" || | |
| 1528 | event.type === "turn.completed" | |
| 1529 | ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({ | |
| 1530 | threadId: thread.id, | |
| 1531 | }) | |
| 1532 | : Option.none(); | |
| 1521 | 1533 | const hasPendingTurnStart = |
| 1522 | 1534 | Option.isSome(pendingTurnStart) && thread.session?.status === "starting"; |
| 1523 | 1535 |
apps/server/src/project/RepositoryIdentityResolver.test.ts+84| ... | ... | @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | 6 | import * as Layer from "effect/Layer"; |
| 7 | 7 | import * as Path from "effect/Path"; |
| 8 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 8 | 9 | import { TestClock } from "effect/testing"; |
| 9 | 10 | |
| 10 | 11 | import * as ProcessRunner from "../processRunner.ts"; |
| ... | ... | @@ -35,6 +36,89 @@ const makeRepositoryIdentityResolverTestLayer = (options: { |
| 35 | 36 | ).pipe(Layer.provide(ProcessRunner.layer)); |
| 36 | 37 | |
| 37 | 38 | it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 39 | it.effect("reuses the cached Git root for repeated workspace lookups", () => { | |
| 40 | const calls: Array<ReadonlyArray<string>> = []; | |
| 41 | const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { | |
| 42 | run: (input) => | |
| 43 | Effect.sync(() => { | |
| 44 | calls.push(input.args); | |
| 45 | return { | |
| 46 | stdout: input.args.includes("rev-parse") | |
| 47 | ? "/repo\n" | |
| 48 | : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", | |
| 49 | stderr: "", | |
| 50 | code: ChildProcessSpawner.ExitCode(0), | |
| 51 | timedOut: false, | |
| 52 | stdoutTruncated: false, | |
| 53 | stderrTruncated: false, | |
| 54 | stdoutInvalidUtf8: false, | |
| 55 | stderrInvalidUtf8: false, | |
| 56 | }; | |
| 57 | }), | |
| 58 | }); | |
| 59 | const resolverLayer = Layer.effect( | |
| 60 | RepositoryIdentityResolver.RepositoryIdentityResolver, | |
| 61 | RepositoryIdentityResolver.make(), | |
| 62 | ).pipe(Layer.provide(processRunner)); | |
| 63 | ||
| 64 | return Effect.gen(function* () { | |
| 65 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | |
| 66 | const first = yield* resolver.resolve("/repo/packages/web"); | |
| 67 | const second = yield* resolver.resolve("/repo/packages/web"); | |
| 68 | ||
| 69 | expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); | |
| 70 | expect(second).toEqual(first); | |
| 71 | expect(calls).toEqual([ | |
| 72 | ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], | |
| 73 | ["-C", "/repo", "remote", "-v"], | |
| 74 | ]); | |
| 75 | }).pipe(Effect.provide(resolverLayer)); | |
| 76 | }); | |
| 77 | ||
| 78 | it.effect("retries Git root discovery after a failed lookup", () => { | |
| 79 | const calls: Array<ReadonlyArray<string>> = []; | |
| 80 | let rootAttempts = 0; | |
| 81 | const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { | |
| 82 | run: (input) => | |
| 83 | Effect.sync(() => { | |
| 84 | calls.push(input.args); | |
| 85 | const rootLookup = input.args.includes("rev-parse"); | |
| 86 | const failed = rootLookup && rootAttempts++ === 0; | |
| 87 | return { | |
| 88 | stdout: rootLookup | |
| 89 | ? failed | |
| 90 | ? "" | |
| 91 | : "/repo\n" | |
| 92 | : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", | |
| 93 | stderr: failed ? "temporary Git failure" : "", | |
| 94 | code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), | |
| 95 | timedOut: false, | |
| 96 | stdoutTruncated: false, | |
| 97 | stderrTruncated: false, | |
| 98 | stdoutInvalidUtf8: false, | |
| 99 | stderrInvalidUtf8: false, | |
| 100 | }; | |
| 101 | }), | |
| 102 | }); | |
| 103 | const resolverLayer = Layer.effect( | |
| 104 | RepositoryIdentityResolver.RepositoryIdentityResolver, | |
| 105 | RepositoryIdentityResolver.make(), | |
| 106 | ).pipe(Layer.provide(processRunner)); | |
| 107 | ||
| 108 | return Effect.gen(function* () { | |
| 109 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | |
| 110 | expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); | |
| 111 | ||
| 112 | const recovered = yield* resolver.resolve("/repo/packages/web"); | |
| 113 | expect(recovered?.rootPath).toBe("/repo"); | |
| 114 | expect(calls).toEqual([ | |
| 115 | ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], | |
| 116 | ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], | |
| 117 | ["-C", "/repo", "remote", "-v"], | |
| 118 | ]); | |
| 119 | }).pipe(Effect.provide(resolverLayer)); | |
| 120 | }); | |
| 121 | ||
| 38 | 122 | it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => |
| 39 | 123 | Effect.gen(function* () { |
| 40 | 124 | const fileSystem = yield* FileSystem.FileSystem; |
apps/server/src/project/RepositoryIdentityResolver.ts+21-11| ... | ... | @@ -90,7 +90,6 @@ function buildRepositoryIdentity(input: { |
| 90 | 90 | const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( |
| 91 | 91 | function* (cwd: string) { |
| 92 | 92 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 93 | let cacheKey = cwd; | |
| 94 | 93 | |
| 95 | 94 | // git is a real executable on every platform — no cmd.exe shell mode, which |
| 96 | 95 | // would split paths containing spaces during cmd's re-tokenization. |
| ... | ... | @@ -102,15 +101,11 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. |
| 102 | 101 | }) |
| 103 | 102 | .pipe(Effect.option); |
| 104 | 103 | if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { |
| 105 | return cacheKey; | |
| 104 | return null; | |
| 106 | 105 | } |
| 107 | 106 | |
| 108 | 107 | const candidate = topLevelResult.value.stdout.trim(); |
| 109 | if (candidate.length > 0) { | |
| 110 | cacheKey = candidate; | |
| 111 | } | |
| 112 | ||
| 113 | return cacheKey; | |
| 108 | return candidate.length > 0 ? candidate : null; | |
| 114 | 109 | }, |
| 115 | 110 | ); |
| 116 | 111 | |
| ... | ... | @@ -139,6 +134,22 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( |
| 139 | 134 | options: RepositoryIdentityResolverOptions = {}, |
| 140 | 135 | ) { |
| 141 | 136 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 137 | const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; | |
| 138 | ||
| 139 | const repositoryRootCache = yield* Cache.makeWith<string, string | null>( | |
| 140 | (cwd) => | |
| 141 | resolveRepositoryIdentityCacheKey(cwd).pipe( | |
| 142 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | |
| 143 | ), | |
| 144 | { | |
| 145 | capacity: cacheCapacity, | |
| 146 | timeToLive: Exit.match({ | |
| 147 | onSuccess: (value) => | |
| 148 | value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), | |
| 149 | onFailure: () => Duration.zero, | |
| 150 | }), | |
| 151 | }, | |
| 152 | ); | |
| 142 | 153 | |
| 143 | 154 | const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>( |
| 144 | 155 | (cacheKey) => |
| ... | ... | @@ -146,7 +157,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( |
| 146 | 157 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), |
| 147 | 158 | ), |
| 148 | 159 | { |
| 149 | capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, | |
| 160 | capacity: cacheCapacity, | |
| 150 | 161 | timeToLive: Exit.match({ |
| 151 | 162 | onSuccess: (value) => |
| 152 | 163 | value === null |
| ... | ... | @@ -160,9 +171,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( |
| 160 | 171 | const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( |
| 161 | 172 | "RepositoryIdentityResolver.resolve", |
| 162 | 173 | )(function* (cwd) { |
| 163 | const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( | |
| 164 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | |
| 165 | ); | |
| 174 | const cacheKey = yield* Cache.get(repositoryRootCache, cwd); | |
| 175 | if (cacheKey === null) return null; | |
| 166 | 176 | return yield* Cache.get(repositoryIdentityCache, cacheKey); |
| 167 | 177 | }); |
| 168 | 178 |
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+42-2| ... | ... | @@ -286,7 +286,7 @@ describe("EventNdjsonLogger", () => { |
| 286 | 286 | }), |
| 287 | 287 | ); |
| 288 | 288 | |
| 289 | it.effect("drops transient canonical events before serialization", () => | |
| 289 | it.effect("drops transient provider events before serialization", () => | |
| 290 | 290 | Effect.gen(function* () { |
| 291 | 291 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); |
| 292 | 292 | const basePath = NodePath.join(tempDir, "events.log"); |
| ... | ... | @@ -302,6 +302,46 @@ describe("EventNdjsonLogger", () => { |
| 302 | 302 | yield* canonical.write(circularDelta, threadId); |
| 303 | 303 | yield* canonical.write({ type: "item.completed", id: "final" }, threadId); |
| 304 | 304 | yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); |
| 305 | yield* native.write( | |
| 306 | { method: "item/agentMessage/delta", payload: circularDelta }, | |
| 307 | threadId, | |
| 308 | ); | |
| 309 | yield* native.write( | |
| 310 | { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, | |
| 311 | threadId, | |
| 312 | ); | |
| 313 | yield* native.write( | |
| 314 | { method: "thread/realtime/transcript/delta", payload: circularDelta }, | |
| 315 | threadId, | |
| 316 | ); | |
| 317 | yield* native.write( | |
| 318 | { | |
| 319 | event: { | |
| 320 | method: "claude/stream_event/content_block_delta/text_delta", | |
| 321 | payload: circularDelta, | |
| 322 | }, | |
| 323 | }, | |
| 324 | threadId, | |
| 325 | ); | |
| 326 | yield* native.write( | |
| 327 | { | |
| 328 | event: { | |
| 329 | method: "session/update", | |
| 330 | payload: { update: { sessionUpdate: "agent_message_chunk" } }, | |
| 331 | }, | |
| 332 | }, | |
| 333 | threadId, | |
| 334 | ); | |
| 335 | yield* native.write( | |
| 336 | { | |
| 337 | event: { | |
| 338 | type: "message.part.updated", | |
| 339 | payload: { properties: { part: { type: "text" } } }, | |
| 340 | }, | |
| 341 | }, | |
| 342 | threadId, | |
| 343 | ); | |
| 344 | yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); | |
| 305 | 345 | yield* store.close(); |
| 306 | 346 | |
| 307 | 347 | const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") |
| ... | ... | @@ -313,7 +353,7 @@ describe("EventNdjsonLogger", () => { |
| 313 | 353 | lines.map(({ stream, payload }) => ({ stream, payload })), |
| 314 | 354 | [ |
| 315 | 355 | { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, |
| 316 | { stream: "NTIVE", payload: '{"type":"content.delta","id":"native-delta"}' }, | |
| 356 | { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, | |
| 317 | 357 | ], |
| 318 | 358 | ); |
| 319 | 359 | } finally { |
apps/server/src/provider/Layers/EventNdjsonLogger.ts+54-7| ... | ... | @@ -45,6 +45,17 @@ const transientCanonicalEventTypes = new Set([ |
| 45 | 45 | "tool.progress", |
| 46 | 46 | "turn.proposed.delta", |
| 47 | 47 | ]); |
| 48 | const transientNativeMethods = new Set([ | |
| 49 | "item/agentMessage/delta", | |
| 50 | "item/commandExecution/outputDelta", | |
| 51 | "item/fileChange/outputDelta", | |
| 52 | "item/plan/delta", | |
| 53 | "item/reasoning/summaryTextDelta", | |
| 54 | "item/reasoning/textDelta", | |
| 55 | "thread/realtime/outputAudio/delta", | |
| 56 | "thread/realtime/transcript/delta", | |
| 57 | ]); | |
| 58 | const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); | |
| 48 | 59 | |
| 49 | 60 | export type EventNdjsonStream = "native" | "canonical" | "orchestration"; |
| 50 | 61 | |
| ... | ... | @@ -126,7 +137,7 @@ export interface PendingRecord { |
| 126 | 137 | } |
| 127 | 138 | |
| 128 | 139 | interface StoreState { |
| 129 | readonly pending: ReadonlyArray<PendingRecord>; | |
| 140 | readonly pending: Array<PendingRecord>; | |
| 130 | 141 | readonly pendingBytes: number; |
| 131 | 142 | readonly sinks: ReadonlyMap<string, RotatingFileSink>; |
| 132 | 143 | readonly flushScheduled: boolean; |
| ... | ... | @@ -178,12 +189,50 @@ function providerLogPath(directory: string, prefix: string, threadSegment: strin |
| 178 | 189 | } |
| 179 | 190 | |
| 180 | 191 | function shouldPersist(stream: EventNdjsonStream, event: unknown): boolean { |
| 181 | if (stream !== "canonical" || typeof event !== "object" || event === null) { | |
| 192 | if (stream === "orchestration" || typeof event !== "object" || event === null) { | |
| 182 | 193 | return true; |
| 183 | 194 | } |
| 184 | 195 | try { |
| 185 | 196 | const type = Reflect.get(event, "type"); |
| 186 | return typeof type !== "string" || !transientCanonicalEventTypes.has(type); | |
| 197 | if (typeof type === "string" && transientCanonicalEventTypes.has(type)) { | |
| 198 | return false; | |
| 199 | } | |
| 200 | if (stream !== "native") return true; | |
| 201 | ||
| 202 | const nested = Reflect.get(event, "event"); | |
| 203 | const nativeEvent = typeof nested === "object" && nested !== null ? nested : event; | |
| 204 | const method = Reflect.get(nativeEvent, "method"); | |
| 205 | if ( | |
| 206 | typeof method === "string" && | |
| 207 | (transientNativeMethods.has(method) || | |
| 208 | method.startsWith("claude/stream_event/content_block_delta/")) | |
| 209 | ) { | |
| 210 | return false; | |
| 211 | } | |
| 212 | ||
| 213 | const nativeType = Reflect.get(nativeEvent, "type"); | |
| 214 | if (nativeType === "message.part.delta") return false; | |
| 215 | ||
| 216 | const payload = Reflect.get(nativeEvent, "payload"); | |
| 217 | if (typeof payload !== "object" || payload === null) return true; | |
| 218 | ||
| 219 | if (method === "session/update") { | |
| 220 | const update = Reflect.get(payload, "update"); | |
| 221 | if (typeof update !== "object" || update === null) return true; | |
| 222 | const updateType = Reflect.get(update, "sessionUpdate"); | |
| 223 | return typeof updateType !== "string" || !transientAcpUpdates.has(updateType); | |
| 224 | } | |
| 225 | ||
| 226 | if (nativeType === "message.part.updated") { | |
| 227 | const properties = Reflect.get(payload, "properties"); | |
| 228 | if (typeof properties !== "object" || properties === null) return true; | |
| 229 | const part = Reflect.get(properties, "part"); | |
| 230 | if (typeof part !== "object" || part === null) return true; | |
| 231 | const partType = Reflect.get(part, "type"); | |
| 232 | return partType !== "text" && partType !== "reasoning"; | |
| 233 | } | |
| 234 | ||
| 235 | return true; | |
| 187 | 236 | } catch { |
| 188 | 237 | return true; |
| 189 | 238 | } |
| ... | ... | @@ -566,10 +615,8 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( |
| 566 | 615 | if (state.closed) { |
| 567 | 616 | return Effect.succeed([{ flush: false }, state] as const); |
| 568 | 617 | } |
| 569 | const pending = [ | |
| 570 | ...state.pending, | |
| 571 | { stream, threadSegment: resolveThreadSegment(threadId), line, bytes }, | |
| 572 | ]; | |
| 618 | const pending = state.pending; | |
| 619 | pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes }); | |
| 573 | 620 | const pendingBytes = state.pendingBytes + bytes; |
| 574 | 621 | const flush = |
| 575 | 622 | resolved.batchWindowMs === 0 || |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+15| ... | ... | @@ -4607,12 +4607,27 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 4607 | 4607 | const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); |
| 4608 | 4608 | const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); |
| 4609 | 4609 | const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); |
| 4610 | const appendedUpdate = mergeOpenCodeAssistantText("Hello", "Hello world"); | |
| 4611 | const changedUpdate = mergeOpenCodeAssistantText("Hello world", "Hello there"); | |
| 4612 | const staleUpdate = mergeOpenCodeAssistantText("Hello world", "Hello"); | |
| 4610 | 4613 | |
| 4611 | 4614 | NodeAssert.deepEqual( |
| 4612 | 4615 | [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], |
| 4613 | 4616 | ["Hello", "lo world", ""], |
| 4614 | 4617 | ); |
| 4615 | 4618 | NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); |
| 4619 | NodeAssert.deepEqual(appendedUpdate, { | |
| 4620 | latestText: "Hello world", | |
| 4621 | deltaToEmit: " world", | |
| 4622 | }); | |
| 4623 | NodeAssert.deepEqual(changedUpdate, { | |
| 4624 | latestText: "Hello there", | |
| 4625 | deltaToEmit: "there", | |
| 4626 | }); | |
| 4627 | NodeAssert.deepEqual(staleUpdate, { | |
| 4628 | latestText: "Hello world", | |
| 4629 | deltaToEmit: "", | |
| 4630 | }); | |
| 4616 | 4631 | }), |
| 4617 | 4632 | ); |
| 4618 | 4633 |
apps/server/src/provider/Layers/OpenCodeAdapter.ts+5-1| ... | ... | @@ -565,9 +565,13 @@ export function mergeOpenCodeAssistantText( |
| 565 | 565 | readonly deltaToEmit: string; |
| 566 | 566 | } { |
| 567 | 567 | const latestText = resolveLatestAssistantText(previousText, nextText); |
| 568 | const previous = previousText ?? ""; | |
| 569 | const prefixLength = latestText.startsWith(previous) | |
| 570 | ? previous.length | |
| 571 | : commonPrefixLength(previous, latestText); | |
| 568 | 572 | return { |
| 569 | 573 | latestText, |
| 570 | deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), | |
| 574 | deltaToEmit: latestText.slice(prefixLength), | |
| 571 | 575 | }; |
| 572 | 576 | } |
| 573 | 577 |
apps/server/src/provider/acp/AcpNativeLogging.test.ts+169| ... | ... | @@ -28,6 +28,7 @@ nodeServicesIt("ACP native logging", (it) => { |
| 28 | 28 | nativeEventLogger, |
| 29 | 29 | provider: ProviderDriverKind.make("cursor"), |
| 30 | 30 | threadId: ThreadId.make("thread-1"), |
| 31 | verboseProtocolLogging: true, | |
| 31 | 32 | }); |
| 32 | 33 | const secret = "secret-token-value"; |
| 33 | 34 | const requestLogger = logger.requestLogger; |
| ... | ... | @@ -67,6 +68,174 @@ nodeServicesIt("ACP native logging", (it) => { |
| 67 | 68 | }), |
| 68 | 69 | ); |
| 69 | 70 | |
| 71 | it.effect("keeps request diagnostics without enabling full protocol logging", () => | |
| 72 | Effect.gen(function* () { | |
| 73 | const records: Array<unknown> = []; | |
| 74 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | |
| 75 | const logger = makeLogger({ | |
| 76 | nativeEventLogger: { | |
| 77 | filePath: "/tmp/provider-native.ndjson", | |
| 78 | write: (event) => Effect.sync(() => void records.push(event)), | |
| 79 | close: () => Effect.void, | |
| 80 | }, | |
| 81 | provider: ProviderDriverKind.make("grok"), | |
| 82 | threadId: ThreadId.make("thread-1"), | |
| 83 | }); | |
| 84 | ||
| 85 | assert.isUndefined(logger.protocolLogging); | |
| 86 | const requestLogger = logger.requestLogger; | |
| 87 | assert.exists(requestLogger); | |
| 88 | if (!requestLogger) return; | |
| 89 | yield* requestLogger({ | |
| 90 | method: "session/prompt", | |
| 91 | payload: {}, | |
| 92 | status: "started", | |
| 93 | }); | |
| 94 | assert.lengthOf(records, 1); | |
| 95 | }), | |
| 96 | ); | |
| 97 | ||
| 98 | it.effect("drops transient ACP chunks before formatting verbose protocol logs", () => | |
| 99 | Effect.gen(function* () { | |
| 100 | const records: Array<unknown> = []; | |
| 101 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | |
| 102 | const logger = makeLogger({ | |
| 103 | nativeEventLogger: { | |
| 104 | filePath: "/tmp/provider-native.ndjson", | |
| 105 | write: (event) => Effect.sync(() => void records.push(event)), | |
| 106 | close: () => Effect.void, | |
| 107 | }, | |
| 108 | provider: ProviderDriverKind.make("cursor"), | |
| 109 | threadId: ThreadId.make("thread-1"), | |
| 110 | verboseProtocolLogging: true, | |
| 111 | }); | |
| 112 | const protocolLogger = logger.protocolLogging?.logger; | |
| 113 | assert.exists(protocolLogger); | |
| 114 | if (!protocolLogger) return; | |
| 115 | ||
| 116 | for (const updateType of ["agent_message_chunk", "agent_thought_chunk"] as const) { | |
| 117 | yield* protocolLogger({ | |
| 118 | direction: "incoming", | |
| 119 | stage: "raw", | |
| 120 | payload: `${encodeUnknownJson({ | |
| 121 | method: "session/update", | |
| 122 | params: { update: { sessionUpdate: updateType } }, | |
| 123 | })}\n`, | |
| 124 | }); | |
| 125 | yield* protocolLogger({ | |
| 126 | direction: "incoming", | |
| 127 | stage: "decoded", | |
| 128 | payload: [ | |
| 129 | { | |
| 130 | _tag: "Request", | |
| 131 | tag: "session/update", | |
| 132 | payload: { update: { sessionUpdate: updateType } }, | |
| 133 | }, | |
| 134 | ], | |
| 135 | }); | |
| 136 | } | |
| 137 | ||
| 138 | assert.lengthOf(records, 0); | |
| 139 | ||
| 140 | yield* protocolLogger({ | |
| 141 | direction: "incoming", | |
| 142 | stage: "decoded", | |
| 143 | payload: [ | |
| 144 | { | |
| 145 | _tag: "Request", | |
| 146 | tag: "session/update", | |
| 147 | payload: { update: { sessionUpdate: "tool_call" } }, | |
| 148 | }, | |
| 149 | ], | |
| 150 | }); | |
| 151 | assert.lengthOf(records, 1); | |
| 152 | }), | |
| 153 | ); | |
| 154 | ||
| 155 | it.effect("keeps mixed and incomplete raw diagnostics", () => | |
| 156 | Effect.gen(function* () { | |
| 157 | const records: Array<unknown> = []; | |
| 158 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | |
| 159 | const logger = makeLogger({ | |
| 160 | nativeEventLogger: { | |
| 161 | filePath: "/tmp/provider-native.ndjson", | |
| 162 | write: (event) => Effect.sync(() => void records.push(event)), | |
| 163 | close: () => Effect.void, | |
| 164 | }, | |
| 165 | provider: ProviderDriverKind.make("cursor"), | |
| 166 | threadId: ThreadId.make("thread-1"), | |
| 167 | verboseProtocolLogging: true, | |
| 168 | }); | |
| 169 | const protocolLogger = logger.protocolLogging?.logger; | |
| 170 | assert.exists(protocolLogger); | |
| 171 | if (!protocolLogger) return; | |
| 172 | ||
| 173 | const transient = encodeUnknownJson({ | |
| 174 | method: "session/update", | |
| 175 | params: { update: { sessionUpdate: "agent_message_chunk" } }, | |
| 176 | }); | |
| 177 | const lifecycle = encodeUnknownJson({ method: "session/new", params: {} }); | |
| 178 | ||
| 179 | yield* protocolLogger({ | |
| 180 | direction: "incoming", | |
| 181 | stage: "raw", | |
| 182 | payload: `${transient}\n${lifecycle}\n`, | |
| 183 | }); | |
| 184 | yield* protocolLogger({ | |
| 185 | direction: "incoming", | |
| 186 | stage: "raw", | |
| 187 | payload: transient, | |
| 188 | }); | |
| 189 | yield* protocolLogger({ | |
| 190 | direction: "incoming", | |
| 191 | stage: "raw", | |
| 192 | payload: `${transient}\n{malformed}\n`, | |
| 193 | }); | |
| 194 | ||
| 195 | assert.lengthOf(records, 3); | |
| 196 | }), | |
| 197 | ); | |
| 198 | ||
| 199 | it.effect("filters transient entries from mixed decoded batches", () => | |
| 200 | Effect.gen(function* () { | |
| 201 | const records: Array<unknown> = []; | |
| 202 | const makeLogger = yield* makeAcpNativeLoggerFactory(); | |
| 203 | const logger = makeLogger({ | |
| 204 | nativeEventLogger: { | |
| 205 | filePath: "/tmp/provider-native.ndjson", | |
| 206 | write: (event) => Effect.sync(() => void records.push(event)), | |
| 207 | close: () => Effect.void, | |
| 208 | }, | |
| 209 | provider: ProviderDriverKind.make("grok"), | |
| 210 | threadId: ThreadId.make("thread-1"), | |
| 211 | verboseProtocolLogging: true, | |
| 212 | }); | |
| 213 | const protocolLogger = logger.protocolLogging?.logger; | |
| 214 | assert.exists(protocolLogger); | |
| 215 | if (!protocolLogger) return; | |
| 216 | ||
| 217 | yield* protocolLogger({ | |
| 218 | direction: "incoming", | |
| 219 | stage: "decoded", | |
| 220 | payload: [ | |
| 221 | { | |
| 222 | _tag: "Request", | |
| 223 | tag: "session/update", | |
| 224 | payload: { update: { sessionUpdate: "agent_thought_chunk" } }, | |
| 225 | }, | |
| 226 | { | |
| 227 | _tag: "Request", | |
| 228 | tag: "session/new", | |
| 229 | payload: {}, | |
| 230 | }, | |
| 231 | ], | |
| 232 | }); | |
| 233 | ||
| 234 | assert.lengthOf(records, 1); | |
| 235 | assert.include(encodeUnknownJson(records), '"itemCount":1'); | |
| 236 | }), | |
| 237 | ); | |
| 238 | ||
| 70 | 239 | it.effect("logs a structural tag when the native writer defects", () => { |
| 71 | 240 | const messages: Array<unknown> = []; |
| 72 | 241 | const logCapture = Logger.make<unknown, void>(({ message }) => { |
apps/server/src/provider/acp/AcpNativeLogging.ts+61-6| ... | ... | @@ -9,6 +9,8 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 9 | 9 | import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; |
| 10 | 10 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 11 | 11 | |
| 12 | const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); | |
| 13 | ||
| 12 | 14 | function structuralMethod(value: string): string { |
| 13 | 15 | return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; |
| 14 | 16 | } |
| ... | ... | @@ -64,12 +66,61 @@ function formatProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) |
| 64 | 66 | }; |
| 65 | 67 | } |
| 66 | 68 | |
| 69 | function isTransientProtocolMessage(message: unknown): boolean { | |
| 70 | if (typeof message !== "object" || message === null) return false; | |
| 71 | const method = Reflect.get(message, "tag") ?? Reflect.get(message, "method"); | |
| 72 | if (method !== "session/update") return false; | |
| 73 | ||
| 74 | const payload = Reflect.get(message, "payload") ?? Reflect.get(message, "params"); | |
| 75 | if (typeof payload !== "object" || payload === null) return false; | |
| 76 | const update = Reflect.get(payload, "update"); | |
| 77 | if (typeof update !== "object" || update === null) return false; | |
| 78 | const updateType = Reflect.get(update, "sessionUpdate"); | |
| 79 | return typeof updateType === "string" && transientProtocolUpdates.has(updateType); | |
| 80 | } | |
| 81 | ||
| 82 | function rawChunkContainsOnlyTransientMessages(payload: string): boolean { | |
| 83 | const lines = payload.split("\n"); | |
| 84 | const remainder = lines.pop() ?? ""; | |
| 85 | if (remainder.trim().length > 0) return false; | |
| 86 | ||
| 87 | const messages: Array<unknown> = []; | |
| 88 | for (const line of lines) { | |
| 89 | if (line.trim().length === 0) continue; | |
| 90 | try { | |
| 91 | messages.push(JSON.parse(line)); | |
| 92 | } catch { | |
| 93 | return false; | |
| 94 | } | |
| 95 | } | |
| 96 | return messages.length > 0 && messages.every(isTransientProtocolMessage); | |
| 97 | } | |
| 98 | ||
| 99 | function filterTransientProtocolLog( | |
| 100 | event: EffectAcpProtocol.AcpProtocolLogEvent, | |
| 101 | ): EffectAcpProtocol.AcpProtocolLogEvent | undefined { | |
| 102 | if (event.direction !== "incoming") return event; | |
| 103 | ||
| 104 | if (event.stage === "raw" && typeof event.payload === "string") { | |
| 105 | return rawChunkContainsOnlyTransientMessages(event.payload) ? undefined : event; | |
| 106 | } | |
| 107 | ||
| 108 | if (event.stage !== "decoded") return event; | |
| 109 | if (!Array.isArray(event.payload)) { | |
| 110 | return isTransientProtocolMessage(event.payload) ? undefined : event; | |
| 111 | } | |
| 112 | ||
| 113 | const payload = event.payload.filter((message) => !isTransientProtocolMessage(message)); | |
| 114 | return payload.length === 0 ? undefined : { ...event, payload }; | |
| 115 | } | |
| 116 | ||
| 67 | 117 | export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { |
| 68 | 118 | const crypto = yield* Crypto.Crypto; |
| 69 | 119 | return (input: { |
| 70 | 120 | readonly nativeEventLogger: EventNdjsonLogger | undefined; |
| 71 | 121 | readonly provider: ProviderDriverKind; |
| 72 | 122 | readonly threadId: ThreadId; |
| 123 | readonly verboseProtocolLogging?: boolean; | |
| 73 | 124 | }): Pick<AcpSessionRuntime.AcpSessionRuntimeOptions, "requestLogger" | "protocolLogging"> => { |
| 74 | 125 | const writeNativeAcpLog = (logInput: { |
| 75 | 126 | readonly kind: "request" | "protocol"; |
| ... | ... | @@ -111,16 +162,20 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" |
| 111 | 162 | kind: "request", |
| 112 | 163 | payload: formatRequestLogPayload(event), |
| 113 | 164 | }), |
| 114 | ...(input.nativeEventLogger | |
| 165 | ...(input.nativeEventLogger && input.verboseProtocolLogging | |
| 115 | 166 | ? { |
| 116 | 167 | protocolLogging: { |
| 117 | 168 | logIncoming: true, |
| 118 | 169 | logOutgoing: true, |
| 119 | logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => | |
| 120 | writeNativeAcpLog({ | |
| 121 | kind: "protocol", | |
| 122 | payload: formatProtocolLogPayload(event), | |
| 123 | }), | |
| 170 | logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { | |
| 171 | const filtered = filterTransientProtocolLog(event); | |
| 172 | return filtered | |
| 173 | ? writeNativeAcpLog({ | |
| 174 | kind: "protocol", | |
| 175 | payload: formatProtocolLogPayload(filtered), | |
| 176 | }) | |
| 177 | : Effect.void; | |
| 178 | }, | |
| 124 | 179 | } satisfies NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"]>, |
| 125 | 180 | } |
| 126 | 181 | : {}), |
apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts+3-2| ... | ... | @@ -44,7 +44,7 @@ describe("resolveNativeSampleIntervalMs", () => { |
| 44 | 44 | expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000); |
| 45 | 45 | }); |
| 46 | 46 | |
| 47 | it("keeps unknown background telemetry cheap but serves live diagnostics at 1Hz", () => { | |
| 47 | it("slows background telemetry and serves live diagnostics at 1Hz", () => { | |
| 48 | 48 | const unknown: HostPowerSnapshot = { |
| 49 | 49 | ...basePower, |
| 50 | 50 | source: "unknown", |
| ... | ... | @@ -58,7 +58,8 @@ describe("resolveNativeSampleIntervalMs", () => { |
| 58 | 58 | 0, |
| 59 | 59 | ), |
| 60 | 60 | ).toBe(5_000); |
| 61 | expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(1_000); | |
| 61 | expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(5_000); | |
| 62 | expect(resolveNativeSampleIntervalMs(basePower, 1)).toBe(1_000); | |
| 62 | 63 | }); |
| 63 | 64 | }); |
| 64 | 65 |
apps/server/src/resourceTelemetry/NativeTelemetryClient.ts+23-17| ... | ... | @@ -268,7 +268,7 @@ export function resolveNativeSampleIntervalMs( |
| 268 | 268 | return CONSTRAINED_SAMPLE_INTERVAL_MS; |
| 269 | 269 | } |
| 270 | 270 | if (snapshot.onBattery === "true") return BATTERY_SAMPLE_INTERVAL_MS; |
| 271 | return SAMPLE_INTERVAL_MS; | |
| 271 | return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS; | |
| 272 | 272 | } |
| 273 | 273 | |
| 274 | 274 | export function commitCollectionControlUpdate<E, R>( |
| ... | ... | @@ -462,13 +462,16 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu |
| 462 | 462 | return Effect.gen(function* () { |
| 463 | 463 | const nativeSnapshot = { generation, snapshot: event } satisfies NativeTelemetrySnapshot; |
| 464 | 464 | const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs); |
| 465 | yield* Ref.update(state, (current) => ({ | |
| 466 | ...current, | |
| 467 | status: "healthy" as const, | |
| 468 | lastSampleAt: Option.some(sampledAt), | |
| 469 | lastError: Option.none(), | |
| 470 | })); | |
| 471 | yield* publishHealth; | |
| 465 | const healthChanged = yield* Ref.modify(state, (current) => [ | |
| 466 | current.status !== "healthy" || Option.isSome(current.lastError), | |
| 467 | { | |
| 468 | ...current, | |
| 469 | status: "healthy" as const, | |
| 470 | lastSampleAt: Option.some(sampledAt), | |
| 471 | lastError: Option.none(), | |
| 472 | }, | |
| 473 | ]); | |
| 474 | if (healthChanged) yield* publishHealth; | |
| 472 | 475 | yield* PubSub.publish(snapshots, nativeSnapshot); |
| 473 | 476 | if (event.requestId) { |
| 474 | 477 | const deferred = yield* Ref.modify(pendingSamples, (pending) => { |
| ... | ... | @@ -485,15 +488,18 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu |
| 485 | 488 | case "historyChunk": |
| 486 | 489 | return Effect.gen(function* () { |
| 487 | 490 | const latestSnapshot = event.snapshots.at(-1); |
| 488 | yield* Ref.update(state, (current) => ({ | |
| 489 | ...current, | |
| 490 | status: "healthy" as const, | |
| 491 | lastSampleAt: latestSnapshot | |
| 492 | ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) | |
| 493 | : current.lastSampleAt, | |
| 494 | lastError: Option.none(), | |
| 495 | })); | |
| 496 | yield* publishHealth; | |
| 491 | const healthChanged = yield* Ref.modify(state, (current) => [ | |
| 492 | current.status !== "healthy" || Option.isSome(current.lastError), | |
| 493 | { | |
| 494 | ...current, | |
| 495 | status: "healthy" as const, | |
| 496 | lastSampleAt: latestSnapshot | |
| 497 | ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) | |
| 498 | : current.lastSampleAt, | |
| 499 | lastError: Option.none(), | |
| 500 | }, | |
| 501 | ]); | |
| 502 | if (healthChanged) yield* publishHealth; | |
| 497 | 503 | const completed = yield* Ref.modify(pendingHistories, (pending) => { |
| 498 | 504 | const request = pending.get(event.requestId); |
| 499 | 505 | if (!request) return [Option.none(), pending] as const; |
apps/server/src/vcs/GitVcsDriverCore.ts+2-1| ... | ... | @@ -475,6 +475,7 @@ function trace2ChildKey(record: Record<string, unknown>): string | null { |
| 475 | 475 | } |
| 476 | 476 | |
| 477 | 477 | const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); |
| 478 | const decodeTrace2Record = decodeJsonResult(Trace2Record); | |
| 478 | 479 | |
| 479 | 480 | const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( |
| 480 | 481 | input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">, |
| ... | ... | @@ -509,7 +510,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( |
| 509 | 510 | return; |
| 510 | 511 | } |
| 511 | 512 | |
| 512 | const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); | |
| 513 | const traceRecord = decodeTrace2Record(trimmedLine); | |
| 513 | 514 | if (Result.isFailure(traceRecord)) { |
| 514 | 515 | yield* Effect.logDebug( |
| 515 | 516 | `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`, |
docs/internals/resource-telemetry.md+5-4| ... | ... | @@ -103,9 +103,9 @@ power-adaptive interval selected by the server. It collects: |
| 103 | 103 | - resident and virtual memory; |
| 104 | 104 | - cumulative process I/O counters. |
| 105 | 105 | |
| 106 | On Linux, task/thread enumeration is disabled. Command lines are loaded only | |
| 107 | when first needed. This avoids the expensive default behavior of walking every | |
| 108 | `/proc/<pid>/task/<tid>` directory on each refresh. | |
| 106 | On Linux, task/thread enumeration is disabled. Command lines are refreshed with | |
| 107 | each sample so process replacements remain visible. Disabling task enumeration | |
| 108 | avoids walking every `/proc/<pid>/task/<tid>` directory on each refresh. | |
| 109 | 109 | |
| 110 | 110 | ### Process-tree selection |
| 111 | 111 | |
| ... | ... | @@ -142,7 +142,8 @@ The server adjusts native sampling without restarting the sidecar: |
| 142 | 142 | |
| 143 | 143 | - suspended, locked, low-power, or serious/critical thermal state: 15 seconds; |
| 144 | 144 | - battery: 5 seconds; |
| 145 | - normal AC: 1 second; | |
| 145 | - normal AC: 5 seconds in the background and 1 second while live diagnostics is | |
| 146 | open; | |
| 146 | 147 | - unknown or stale power: 5 seconds in the background and 1 second while live |
| 147 | 148 | diagnostics is open. |
| 148 | 149 |
native/resource-monitor/src/main.rs+22-13| ... | ... | @@ -250,6 +250,10 @@ impl HistoryRecorder { |
| 250 | 250 | max_retained_entries: usize, |
| 251 | 251 | max_retained_bytes: usize, |
| 252 | 252 | ) { |
| 253 | let clock_moved_backward = self | |
| 254 | .snapshots | |
| 255 | .back() | |
| 256 | .is_some_and(|previous| previous.sampled_at_unix_ms > snapshot.sampled_at_unix_ms); | |
| 253 | 257 | let mut retained = snapshot.clone(); |
| 254 | 258 | retained.request_id = None; |
| 255 | 259 | self.retained_entry_count = self |
| ... | ... | @@ -264,6 +268,7 @@ impl HistoryRecorder { |
| 264 | 268 | max_snapshots, |
| 265 | 269 | max_retained_entries, |
| 266 | 270 | max_retained_bytes, |
| 271 | clock_moved_backward, | |
| 267 | 272 | ); |
| 268 | 273 | } |
| 269 | 274 | |
| ... | ... | @@ -273,20 +278,24 @@ impl HistoryRecorder { |
| 273 | 278 | max_snapshots: usize, |
| 274 | 279 | max_retained_entries: usize, |
| 275 | 280 | max_retained_bytes: usize, |
| 281 | clock_moved_backward: bool, | |
| 276 | 282 | ) { |
| 277 | let mut future_entry_count = 0usize; | |
| 278 | let mut future_bytes = 0usize; | |
| 279 | self.snapshots.retain(|snapshot| { | |
| 280 | let keep = snapshot.sampled_at_unix_ms <= now_ms; | |
| 281 | if !keep { | |
| 282 | future_entry_count = | |
| 283 | future_entry_count.saturating_add(snapshot.retained_entry_count()); | |
| 284 | future_bytes = future_bytes.saturating_add(snapshot.estimated_history_bytes()); | |
| 285 | } | |
| 286 | keep | |
| 287 | }); | |
| 288 | self.retained_entry_count = self.retained_entry_count.saturating_sub(future_entry_count); | |
| 289 | self.retained_bytes = self.retained_bytes.saturating_sub(future_bytes); | |
| 283 | if clock_moved_backward { | |
| 284 | let mut future_entry_count = 0usize; | |
| 285 | let mut future_bytes = 0usize; | |
| 286 | self.snapshots.retain(|snapshot| { | |
| 287 | let keep = snapshot.sampled_at_unix_ms <= now_ms; | |
| 288 | if !keep { | |
| 289 | future_entry_count = | |
| 290 | future_entry_count.saturating_add(snapshot.retained_entry_count()); | |
| 291 | future_bytes = future_bytes.saturating_add(snapshot.estimated_history_bytes()); | |
| 292 | } | |
| 293 | keep | |
| 294 | }); | |
| 295 | self.retained_entry_count = | |
| 296 | self.retained_entry_count.saturating_sub(future_entry_count); | |
| 297 | self.retained_bytes = self.retained_bytes.saturating_sub(future_bytes); | |
| 298 | } | |
| 290 | 299 | |
| 291 | 300 | while self.snapshots.front().is_some_and(|snapshot| { |
| 292 | 301 | snapshot.sampled_at_unix_ms < now_ms.saturating_sub(HISTORY_RETENTION_MS) |
packages/effect-acp/src/protocol.test.ts+35| ... | ... | @@ -135,6 +135,41 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { |
| 135 | 135 | }), |
| 136 | 136 | ); |
| 137 | 137 | |
| 138 | it.effect("keeps only recent raw notifications after their callbacks run", () => | |
| 139 | Effect.gen(function* () { | |
| 140 | const { stdio, input } = yield* makeInMemoryStdio(); | |
| 141 | const handled = yield* Deferred.make<void>(); | |
| 142 | let handledCount = 0; | |
| 143 | const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ | |
| 144 | stdio, | |
| 145 | serverRequestMethods: new Set(), | |
| 146 | onNotification: () => | |
| 147 | Effect.sync(() => ++handledCount).pipe( | |
| 148 | Effect.flatMap((count) => | |
| 149 | count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, | |
| 150 | ), | |
| 151 | ), | |
| 152 | }); | |
| 153 | ||
| 154 | const messages = Array.from({ length: 64 }, (_, index) => | |
| 155 | encodeUnknownJsonString({ | |
| 156 | jsonrpc: "2.0", | |
| 157 | method: "x/performance", | |
| 158 | params: { index }, | |
| 159 | }), | |
| 160 | ); | |
| 161 | yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); | |
| 162 | yield* Deferred.await(handled); | |
| 163 | ||
| 164 | const retained = yield* transport.incoming.pipe(Stream.take(32), Stream.runCollect); | |
| 165 | ||
| 166 | assert.equal(handledCount, 64); | |
| 167 | assert.equal(retained.length, 32); | |
| 168 | assert.deepEqual(retained[0]?.params, { index: 32 }); | |
| 169 | assert.deepEqual(retained[31]?.params, { index: 63 }); | |
| 170 | }), | |
| 171 | ); | |
| 172 | ||
| 138 | 173 | it.effect("keeps invalid core notification values only in the schema cause", () => |
| 139 | 174 | Effect.gen(function* () { |
| 140 | 175 | const secret = "acp-core-notification-secret-sentinel"; |
packages/effect-acp/src/protocol.ts+12-6| ... | ... | @@ -76,6 +76,7 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect( |
| 76 | 76 | AcpSchema.ElicitationCompleteNotification, |
| 77 | 77 | ); |
| 78 | 78 | const parserFactory = RpcSerialization.ndJsonRpc(); |
| 79 | const MAX_BUFFERED_RAW_NOTIFICATIONS = 32; | |
| 79 | 80 | |
| 80 | 81 | export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( |
| 81 | 82 | options: AcpPatchedProtocolOptions, |
| ... | ... | @@ -83,7 +84,9 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi |
| 83 | 84 | const parser = parserFactory.makeUnsafe(); |
| 84 | 85 | const serverQueue = yield* Queue.unbounded<RpcMessage.FromClientEncoded>(); |
| 85 | 86 | const clientQueue = yield* Queue.unbounded<RpcMessage.FromServerEncoded>(); |
| 86 | const notificationQueue = yield* Queue.unbounded<AcpIncomingNotification>(); | |
| 87 | const notificationQueue = yield* Queue.sliding<AcpIncomingNotification>( | |
| 88 | MAX_BUFFERED_RAW_NOTIFICATIONS, | |
| 89 | ); | |
| 87 | 90 | const disconnects = yield* Queue.unbounded<number>(); |
| 88 | 91 | const outgoing = yield* Queue.unbounded<string | Uint8Array, Cause.Done<void>>(); |
| 89 | 92 | const nextRequestId = yield* Ref.make(1); |
| ... | ... | @@ -408,11 +411,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi |
| 408 | 411 | |
| 409 | 412 | yield* options.stdio.stdin.pipe( |
| 410 | 413 | Stream.runForEach((data) => |
| 411 | logProtocol({ | |
| 412 | direction: "incoming", | |
| 413 | stage: "raw", | |
| 414 | payload: typeof data === "string" ? data : new TextDecoder().decode(data), | |
| 415 | }).pipe( | |
| 414 | (options.logIncoming | |
| 415 | ? logProtocol({ | |
| 416 | direction: "incoming", | |
| 417 | stage: "raw", | |
| 418 | payload: typeof data === "string" ? data : new TextDecoder().decode(data), | |
| 419 | }) | |
| 420 | : Effect.void | |
| 421 | ).pipe( | |
| 416 | 422 | Effect.flatMap(() => |
| 417 | 423 | Effect.try({ |
| 418 | 424 | try: () => |
packages/effect-codex-app-server/src/protocol.test.ts+252| ... | ... | @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Fiber from "effect/Fiber"; |
| 4 | 4 | import * as Queue from "effect/Queue"; |
| 5 | 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as Stdio from "effect/Stdio"; | |
| 6 | 7 | import * as Stream from "effect/Stream"; |
| 7 | 8 | |
| 8 | 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -398,6 +399,257 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { |
| 398 | 399 | }), |
| 399 | 400 | ); |
| 400 | 401 | |
| 402 | it.effect("keeps only recent raw notifications after their callbacks run", () => | |
| 403 | Effect.gen(function* () { | |
| 404 | const { stdio, input } = yield* makeInMemoryStdio(); | |
| 405 | const handled = yield* Deferred.make<void>(); | |
| 406 | let handledCount = 0; | |
| 407 | const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ | |
| 408 | stdio, | |
| 409 | onNotification: () => | |
| 410 | Effect.sync(() => ++handledCount).pipe( | |
| 411 | Effect.flatMap((count) => | |
| 412 | count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, | |
| 413 | ), | |
| 414 | ), | |
| 415 | }); | |
| 416 | ||
| 417 | const messages = Array.from({ length: 64 }, (_, index) => | |
| 418 | encodeUnknownJsonString({ | |
| 419 | method: "item/agentMessage/delta", | |
| 420 | params: { index }, | |
| 421 | }), | |
| 422 | ); | |
| 423 | yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); | |
| 424 | yield* Deferred.await(handled); | |
| 425 | ||
| 426 | const retained = yield* transport.incomingNotifications.pipe( | |
| 427 | Stream.take(32), | |
| 428 | Stream.runCollect, | |
| 429 | ); | |
| 430 | ||
| 431 | assert.equal(handledCount, 64); | |
| 432 | assert.equal(retained.length, 32); | |
| 433 | assert.deepEqual(retained[0]?.params, { index: 32 }); | |
| 434 | assert.deepEqual(retained[31]?.params, { index: 63 }); | |
| 435 | }), | |
| 436 | ); | |
| 437 | ||
| 438 | it.effect("keeps processing protocol messages while an approval is pending", () => | |
| 439 | Effect.gen(function* () { | |
| 440 | const { stdio, input, output } = yield* makeInMemoryStdio(); | |
| 441 | const approvalStarted = yield* Deferred.make<void>(); | |
| 442 | const approvalDecision = yield* Deferred.make<{ readonly decision: string }>(); | |
| 443 | const notificationReceived = yield* Deferred.make<void>(); | |
| 444 | const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ | |
| 445 | stdio, | |
| 446 | onRequest: () => | |
| 447 | Deferred.succeed(approvalStarted, undefined).pipe( | |
| 448 | Effect.andThen(Deferred.await(approvalDecision)), | |
| 449 | ), | |
| 450 | onNotification: () => Deferred.succeed(notificationReceived, undefined).pipe(Effect.asVoid), | |
| 451 | }); | |
| 452 | ||
| 453 | const pendingRequest = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); | |
| 454 | yield* Queue.take(output); | |
| 455 | yield* Queue.offer( | |
| 456 | input, | |
| 457 | encoder.encode( | |
| 458 | `${[ | |
| 459 | encodeUnknownJsonString({ id: 7, method: "item/tool/requestUserInput", params: {} }), | |
| 460 | encodeUnknownJsonString({ method: "item/agentMessage/delta", params: { delta: "ok" } }), | |
| 461 | encodeUnknownJsonString({ id: 1, result: { threadId: "thread-1" } }), | |
| 462 | ].join("\n")}\n`, | |
| 463 | ), | |
| 464 | ); | |
| 465 | ||
| 466 | yield* Deferred.await(approvalStarted); | |
| 467 | yield* Deferred.await(notificationReceived); | |
| 468 | assert.deepEqual(yield* Fiber.join(pendingRequest), { threadId: "thread-1" }); | |
| 469 | ||
| 470 | yield* Deferred.succeed(approvalDecision, { decision: "accept" }); | |
| 471 | assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { | |
| 472 | id: 7, | |
| 473 | result: { decision: "accept" }, | |
| 474 | }); | |
| 475 | }), | |
| 476 | ); | |
| 477 | ||
| 478 | it.effect("rejects incoming requests after the active handler limit is reached", () => | |
| 479 | Effect.gen(function* () { | |
| 480 | const { stdio, input, output } = yield* makeInMemoryStdio(); | |
| 481 | const handlersStarted = yield* Deferred.make<void>(); | |
| 482 | const releaseHandlers = yield* Deferred.make<void>(); | |
| 483 | let activeHandlers = 0; | |
| 484 | yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ | |
| 485 | stdio, | |
| 486 | onRequest: () => | |
| 487 | Effect.sync(() => ++activeHandlers).pipe( | |
| 488 | Effect.flatMap((count) => | |
| 489 | count === 32 | |
| 490 | ? Deferred.succeed(handlersStarted, undefined).pipe(Effect.asVoid) | |
| 491 | : Effect.void, | |
| 492 | ), | |
| 493 | Effect.andThen(Deferred.await(releaseHandlers)), | |
| 494 | Effect.as({ decision: "accept" }), | |
| 495 | ), | |
| 496 | }); | |
| 497 | ||
| 498 | const requests = Array.from({ length: 33 }, (_, index) => | |
| 499 | encodeUnknownJsonString({ | |
| 500 | id: index + 1, | |
| 501 | method: "item/tool/requestUserInput", | |
| 502 | params: {}, | |
| 503 | }), | |
| 504 | ); | |
| 505 | yield* Queue.offer(input, encoder.encode(`${requests.join("\n")}\n`)); | |
| 506 | yield* Deferred.await(handlersStarted); | |
| 507 | ||
| 508 | assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { | |
| 509 | id: 33, | |
| 510 | error: { | |
| 511 | code: -32001, | |
| 512 | message: "Too many Codex requests are already active.", | |
| 513 | }, | |
| 514 | }); | |
| 515 | assert.equal(activeHandlers, 32); | |
| 516 | ||
| 517 | yield* Deferred.succeed(releaseHandlers, undefined); | |
| 518 | yield* Effect.forEach(Array.from({ length: 32 }), () => Queue.take(output), { | |
| 519 | discard: true, | |
| 520 | }); | |
| 521 | }), | |
| 522 | ); | |
| 523 | ||
| 524 | it.effect("interrupts pending request handlers when the protocol terminates", () => | |
| 525 | Effect.gen(function* () { | |
| 526 | const { stdio, input } = yield* makeInMemoryStdio(); | |
| 527 | const approvalStarted = yield* Deferred.make<void>(); | |
| 528 | const approvalInterrupted = yield* Deferred.make<void>(); | |
| 529 | const terminated = yield* Deferred.make<void>(); | |
| 530 | yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ | |
| 531 | stdio, | |
| 532 | onRequest: () => | |
| 533 | Deferred.succeed(approvalStarted, undefined).pipe( | |
| 534 | Effect.andThen(Effect.never), | |
| 535 | Effect.onInterrupt(() => | |
| 536 | Deferred.succeed(approvalInterrupted, undefined).pipe(Effect.asVoid), | |
| 537 | ), | |
| 538 | ), | |
| 539 | onTermination: () => Deferred.succeed(terminated, undefined).pipe(Effect.asVoid), | |
| 540 | }); | |
| 541 | ||
| 542 | yield* Queue.offer( | |
| 543 | input, | |
| 544 | encodeJsonl({ id: 7, method: "item/tool/requestUserInput", params: {} }), | |
| 545 | ); | |
| 546 | yield* Deferred.await(approvalStarted); | |
| 547 | yield* Queue.end(input); | |
| 548 | ||
| 549 | yield* Deferred.await(approvalInterrupted); | |
| 550 | yield* Deferred.await(terminated); | |
| 551 | }), | |
| 552 | ); | |
| 553 | ||
| 554 | it.effect("rejects outgoing messages after an approval response cannot be encoded", () => | |
| 555 | Effect.gen(function* () { | |
| 556 | const { stdio: baseStdio, input } = yield* makeInMemoryStdio(); | |
| 557 | const terminated = yield* Deferred.make<CodexError.CodexAppServerError>(); | |
| 558 | const readerStopped = yield* Deferred.make<void>(); | |
| 559 | let notificationCount = 0; | |
| 560 | let requestCount = 0; | |
| 561 | const stdio = Stdio.make({ | |
| 562 | args: baseStdio.args, | |
| 563 | stdin: baseStdio.stdin.pipe( | |
| 564 | Stream.ensuring(Deferred.succeed(readerStopped, undefined).pipe(Effect.asVoid)), | |
| 565 | ), | |
| 566 | stdout: baseStdio.stdout, | |
| 567 | stderr: baseStdio.stderr, | |
| 568 | }); | |
| 569 | const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ | |
| 570 | stdio, | |
| 571 | onRequest: () => | |
| 572 | Effect.sync(() => ++requestCount).pipe( | |
| 573 | Effect.map((count) => (count === 1 ? { invalid: 1n } : { ok: true })), | |
| 574 | ), | |
| 575 | onNotification: () => Effect.sync(() => notificationCount++).pipe(Effect.asVoid), | |
| 576 | onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), | |
| 577 | }); | |
| 578 | ||
| 579 | yield* Queue.offer( | |
| 580 | input, | |
| 581 | encodeJsonl({ id: 7, method: "item/tool/requestUserInput", params: {} }), | |
| 582 | ); | |
| 583 | ||
| 584 | const failure = yield* Deferred.await(terminated); | |
| 585 | assert.instanceOf(failure, CodexError.CodexAppServerProtocolParseError); | |
| 586 | const requestFailure = yield* transport.request("thread/read", {}).pipe( | |
| 587 | Effect.match({ | |
| 588 | onFailure: (error) => error, | |
| 589 | onSuccess: () => assert.fail("Expected a terminated protocol request to fail"), | |
| 590 | }), | |
| 591 | ); | |
| 592 | const notificationFailure = yield* transport.notify("initialized").pipe(Effect.flip); | |
| 593 | assert.strictEqual(requestFailure, failure); | |
| 594 | assert.strictEqual(notificationFailure, failure); | |
| 595 | yield* Deferred.await(readerStopped); | |
| 596 | ||
| 597 | yield* Queue.offer( | |
| 598 | input, | |
| 599 | encoder.encode( | |
| 600 | `${[ | |
| 601 | encodeUnknownJsonString({ method: "x/late-notification" }), | |
| 602 | encodeUnknownJsonString({ id: 8, method: "x/late-request" }), | |
| 603 | ].join("\n")}\n`, | |
| 604 | ), | |
| 605 | ); | |
| 606 | ||
| 607 | assert.equal(notificationCount, 0); | |
| 608 | assert.equal(requestCount, 1); | |
| 609 | assert.equal(yield* Queue.size(input), 1); | |
| 610 | }), | |
| 611 | ); | |
| 612 | ||
| 613 | it.effect("fails pending requests before interrupted handler cleanup completes", () => | |
| 614 | Effect.gen(function* () { | |
| 615 | const { stdio, input, output } = yield* makeInMemoryStdio(); | |
| 616 | const handlerStarted = yield* Deferred.make<void>(); | |
| 617 | const finalizerStarted = yield* Deferred.make<void>(); | |
| 618 | const releaseFinalizer = yield* Deferred.make<void>(); | |
| 619 | const terminated = yield* Deferred.make<CodexError.CodexAppServerError>(); | |
| 620 | const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ | |
| 621 | stdio, | |
| 622 | onRequest: () => | |
| 623 | Deferred.succeed(handlerStarted, undefined).pipe( | |
| 624 | Effect.andThen(Effect.never), | |
| 625 | Effect.onInterrupt(() => | |
| 626 | Deferred.succeed(finalizerStarted, undefined).pipe( | |
| 627 | Effect.andThen(Deferred.await(releaseFinalizer)), | |
| 628 | ), | |
| 629 | ), | |
| 630 | ), | |
| 631 | onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), | |
| 632 | }); | |
| 633 | const pending = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); | |
| 634 | yield* Queue.take(output); | |
| 635 | yield* Queue.offer(input, encodeJsonl({ id: 7, method: "x/approval" })); | |
| 636 | yield* Deferred.await(handlerStarted); | |
| 637 | yield* Queue.end(input); | |
| 638 | ||
| 639 | const failure = yield* Deferred.await(terminated); | |
| 640 | yield* Deferred.await(finalizerStarted); | |
| 641 | const pendingFailure = yield* Fiber.join(pending).pipe( | |
| 642 | Effect.match({ | |
| 643 | onFailure: (error) => error, | |
| 644 | onSuccess: () => assert.fail("Expected the pending request to fail"), | |
| 645 | }), | |
| 646 | ); | |
| 647 | assert.strictEqual(pendingFailure, failure); | |
| 648 | ||
| 649 | yield* Deferred.succeed(releaseFinalizer, undefined); | |
| 650 | }), | |
| 651 | ); | |
| 652 | ||
| 401 | 653 | it.effect("surfaces JSON encoding failures as protocol parse errors", () => |
| 402 | 654 | Effect.gen(function* () { |
| 403 | 655 | const { stdio } = yield* makeInMemoryStdio(); |
packages/effect-codex-app-server/src/protocol.ts+70-25| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import * as Cause from "effect/Cause"; |
| 2 | 2 | import * as Deferred from "effect/Deferred"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | import * as Option from "effect/Option"; | |
| 4 | 6 | import * as Queue from "effect/Queue"; |
| 5 | 7 | import * as Ref from "effect/Ref"; |
| 6 | 8 | import * as Scope from "effect/Scope"; |
| ... | ... | @@ -13,6 +15,7 @@ import { JsonRpcId, JsonRpcResponseEnvelope } from "./_internal/shared.ts"; |
| 13 | 15 | const isJsonRpcId = Schema.is(JsonRpcId); |
| 14 | 16 | const isJsonRpcResponseEnvelope = Schema.is(JsonRpcResponseEnvelope); |
| 15 | 17 | const isCodexAppServerError = Schema.is(CodexError.CodexAppServerError); |
| 18 | const MAX_BUFFERED_RAW_MESSAGES = 32; | |
| 16 | 19 | |
| 17 | 20 | export interface CodexAppServerProtocolLogEvent { |
| 18 | 21 | readonly direction: "incoming" | "outgoing"; |
| ... | ... | @@ -152,13 +155,20 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 152 | 155 | function* ( |
| 153 | 156 | options: CodexAppServerPatchedProtocolOptions, |
| 154 | 157 | ): Effect.fn.Return<CodexAppServerPatchedProtocol, never, Scope.Scope> { |
| 158 | const protocolScope = yield* Scope.Scope; | |
| 159 | const requestHandlerScope = yield* Scope.fork(protocolScope, "parallel"); | |
| 155 | 160 | const outgoing = yield* Queue.unbounded<string, Cause.Done<void>>(); |
| 156 | const incomingNotifications = yield* Queue.unbounded<CodexAppServerIncomingNotification>(); | |
| 157 | const incomingRequests = yield* Queue.unbounded<CodexAppServerIncomingRequest>(); | |
| 161 | const incomingNotifications = | |
| 162 | yield* Queue.sliding<CodexAppServerIncomingNotification>(MAX_BUFFERED_RAW_MESSAGES); | |
| 163 | const incomingRequests = | |
| 164 | yield* Queue.sliding<CodexAppServerIncomingRequest>(MAX_BUFFERED_RAW_MESSAGES); | |
| 158 | 165 | const pending = yield* Ref.make(new Map<string, CodexAppServerPendingRequest>()); |
| 159 | 166 | const nextRequestId = yield* Ref.make(1); |
| 160 | 167 | const remainder: Array<string> = []; |
| 161 | 168 | const terminationHandled = yield* Ref.make(false); |
| 169 | const terminationFailure = yield* Ref.make(Option.none<CodexError.CodexAppServerError>()); | |
| 170 | const terminationSignal = yield* Deferred.make<void>(); | |
| 171 | const activeRequestHandlers = yield* Ref.make(0); | |
| 162 | 172 | |
| 163 | 173 | const logProtocol = (event: CodexAppServerProtocolLogEvent) => { |
| 164 | 174 | if (event.direction === "incoming" && !options.logIncoming) { |
| ... | ... | @@ -191,8 +201,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 191 | 201 | return [ |
| 192 | 202 | Effect.gen(function* () { |
| 193 | 203 | const error = yield* classify(); |
| 204 | yield* Ref.set(terminationFailure, Option.some(error)); | |
| 194 | 205 | yield* failAllPending(error); |
| 195 | 206 | yield* Queue.end(outgoing); |
| 207 | yield* Deferred.succeed(terminationSignal, undefined); | |
| 208 | yield* Scope.close(requestHandlerScope, Exit.void).pipe( | |
| 209 | Effect.forkIn(protocolScope, { startImmediately: true }), | |
| 210 | Effect.asVoid, | |
| 211 | ); | |
| 196 | 212 | if (options.onTermination) { |
| 197 | 213 | yield* options.onTermination(error); |
| 198 | 214 | } |
| ... | ... | @@ -203,6 +219,9 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 203 | 219 | |
| 204 | 220 | const offerOutgoing = (message: Record<string, unknown>) => |
| 205 | 221 | Effect.gen(function* () { |
| 222 | const failure = yield* Ref.get(terminationFailure); | |
| 223 | if (Option.isSome(failure)) return yield* failure.value; | |
| 224 | ||
| 206 | 225 | yield* logProtocol({ |
| 207 | 226 | direction: "outgoing", |
| 208 | 227 | stage: "decoded", |
| ... | ... | @@ -214,7 +233,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 214 | 233 | stage: "raw", |
| 215 | 234 | payload: encoded, |
| 216 | 235 | }); |
| 217 | yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid); | |
| 236 | const accepted = yield* Queue.offer(outgoing, encoded); | |
| 237 | if (!accepted) { | |
| 238 | const closed = yield* Ref.get(terminationFailure); | |
| 239 | return yield* Option.getOrElse( | |
| 240 | closed, | |
| 241 | () => new CodexError.CodexAppServerInputStreamEndedError({}), | |
| 242 | ); | |
| 243 | } | |
| 218 | 244 | }); |
| 219 | 245 | |
| 220 | 246 | const removePending = (requestId: string) => |
| ... | ... | @@ -271,9 +297,24 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 271 | 297 | |
| 272 | 298 | const handleRequest = (request: CodexAppServerIncomingRequest) => |
| 273 | 299 | Queue.offer(incomingRequests, request).pipe( |
| 274 | Effect.andThen( | |
| 275 | options.onRequest | |
| 276 | ? options.onRequest(request).pipe( | |
| 300 | Effect.flatMap(() => { | |
| 301 | const handler = options.onRequest; | |
| 302 | if (!handler) return Effect.void; | |
| 303 | ||
| 304 | return Ref.modify(activeRequestHandlers, (count) => | |
| 305 | count >= MAX_BUFFERED_RAW_MESSAGES ? [false, count] : [true, count + 1], | |
| 306 | ).pipe( | |
| 307 | Effect.flatMap((accepted) => { | |
| 308 | if (!accepted) { | |
| 309 | return respondError( | |
| 310 | request.id, | |
| 311 | CodexError.CodexAppServerRequestError.overloaded( | |
| 312 | "Too many Codex requests are already active.", | |
| 313 | ), | |
| 314 | ); | |
| 315 | } | |
| 316 | ||
| 317 | return handler(request).pipe( | |
| 277 | 318 | Effect.matchEffect({ |
| 278 | 319 | onFailure: (error) => |
| 279 | 320 | respondError( |
| ... | ... | @@ -285,9 +326,21 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 285 | 326 | ), |
| 286 | 327 | onSuccess: (result) => respond(request.id, result), |
| 287 | 328 | }), |
| 288 | ) | |
| 289 | : Effect.void, | |
| 290 | ), | |
| 329 | Effect.ensuring( | |
| 330 | Ref.update(activeRequestHandlers, (count) => Math.max(0, count - 1)), | |
| 331 | ), | |
| 332 | Effect.catch((error) => | |
| 333 | handleTermination(() => Effect.succeed(error)).pipe( | |
| 334 | Effect.forkIn(protocolScope), | |
| 335 | Effect.asVoid, | |
| 336 | ), | |
| 337 | ), | |
| 338 | Effect.forkIn(requestHandlerScope, { startImmediately: true }), | |
| 339 | Effect.asVoid, | |
| 340 | ); | |
| 341 | }), | |
| 342 | ); | |
| 343 | }), | |
| 291 | 344 | Effect.asVoid, |
| 292 | 345 | ); |
| 293 | 346 | |
| ... | ... | @@ -297,22 +350,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 297 | 350 | Effect.asVoid, |
| 298 | 351 | ); |
| 299 | 352 | |
| 300 | const routeMessage = ( | |
| 301 | message: unknown, | |
| 302 | ): Effect.Effect<void, CodexError.CodexAppServerError> => { | |
| 303 | if (isIncomingRequest(message)) { | |
| 304 | return handleRequest(message); | |
| 305 | } | |
| 306 | if (isIncomingNotification(message)) { | |
| 307 | return handleNotification(message); | |
| 308 | } | |
| 309 | if (isIncomingResponse(message)) { | |
| 310 | return handleResponse(message); | |
| 311 | } | |
| 312 | return Effect.fail( | |
| 313 | CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message), | |
| 314 | ); | |
| 315 | }; | |
| 353 | const routeMessage = Effect.fnUntraced(function* (message: unknown) { | |
| 354 | if (Option.isSome(yield* Ref.get(terminationFailure))) return; | |
| 355 | if (isIncomingRequest(message)) return yield* handleRequest(message); | |
| 356 | if (isIncomingNotification(message)) return yield* handleNotification(message); | |
| 357 | if (isIncomingResponse(message)) return yield* handleResponse(message); | |
| 358 | return yield* CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message); | |
| 359 | }); | |
| 316 | 360 | |
| 317 | 361 | const handleLine = (line: string): Effect.Effect<void, CodexError.CodexAppServerError> => { |
| 318 | 362 | if (line.trim().length === 0) { |
| ... | ... | @@ -352,6 +396,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa |
| 352 | 396 | }; |
| 353 | 397 | |
| 354 | 398 | yield* options.stdio.stdin.pipe( |
| 399 | Stream.interruptWhen(Deferred.await(terminationSignal)), | |
| 355 | 400 | Stream.decodeText(), |
| 356 | 401 | Stream.runForEach((chunk) => |
| 357 | 402 | Effect.sync(() => { |