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
23 changes: 21 additions & 2 deletions UPSTREAM_DIVERGENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,26 @@ Sections are ordered by action:

## Ported in the current cycle

**Cycle:** 2026-04-24 · Baseline before cycle: `7c430aece` · Baseline after cycle: `ececcdcb1`
**Cycle:** 2026-04-28 · Baseline before cycle: `7f04a4a11` · Baseline after cycle: `ef574febf`

### PR #72 — upstream sync (2026-04-28)

| Upstream | Subject | New SHA |
| ------------------------------------------------------ | ---------------------------------------------------------- | ----------- |
| [#2364](https://github.com/pingdotgg/t3code/pull/2364) | fix(release): use configured node for smoke manifest merge | `2f1e3cc3c` |
| [#2372](https://github.com/pingdotgg/t3code/pull/2372) | Ignore stale WebSocket lifecycle events after reconnect | `b408a6857` |

**Conflict resolutions applied:** None — both upstream commits cherry-picked cleanly. Patch context for `wsTransport.ts` / `protocol.ts` / `wsTransport.test.ts` matched MarCode HEAD exactly; only delta is the rebrand strings (`@marcode/contracts`, `"Unable to connect to the MarCode server WebSocket."`), untouched by upstream. `release-smoke.ts` line 260 matched at offset (file is shorter in MarCode because of the removed nightly-channel block, but the heredoc context is identical).

**Note on upstream's new `isActive` socket-session gate:** The new `WsProtocolLifecycleHandlers.isActive?` callback in `protocol.ts` lives at the **socket session** layer (per-session id check inside `WsTransport.createSession`). It is distinct from the existing **per-stream** `isActive` parameter on `runStreamOnSession` in `wsTransport.ts` — they don't collide functionally but a future reader could conflate them.

**Bundled non-upstream commit (out-of-cycle, co-shipped):** `ef574febf feat(provider): subagent task events, cursor allow_once, ACP outgoing logging` — backfill subagent test coverage for `7f04a4a11` (`task.progress` with `lastToolName` / `summary`, child-thread delta suppression), Cursor `allow_once` preference for auto-approval (preserves command-text visibility against the empty-`rawInput` Cursor server bug), and `effect-acp` `sendNotification` rewired through `logProtocol` + JSON-RPC encoding to match request/response paths.

---

## Previous cycle: 2026-04-24

**Baseline before cycle:** `7c430aece` · **Baseline after cycle:** `ececcdcb1`

### Direct-to-main (no PR, user-approved)

Expand Down Expand Up @@ -187,7 +206,7 @@ MarCode ships semver alphas (`1.0.0-alpha.*`), not nightly builds. Adopting nigh

## Pending real work

_None as of 2026-04-24._ Both previously-listed rows (#1996 and #2246) landed in this cycle — #1996 across PRs #69 + #70, #2246 via PR #71. Re-run the `git cherry origin/main upstream/main` workflow at the top of this doc when starting a new cycle to populate this section.
_None as of 2026-04-28._ The two upstream commits in this cycle (#2364 release-smoke node fix + #2372 stale WS lifecycle gate) landed via PR #72. Re-run the `git cherry origin/main upstream/main` workflow at the top of this doc when starting a new cycle to populate this section.

---

Expand Down
262 changes: 262 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1491,6 +1491,268 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
assert.equal(events[0]?.type, "item.completed");
}),
);

// Once a subagent has been spawned, the Codex runtime forwards child-thread
// notifications through the parent's stream (rewriting `event.threadId` to
// the parent's, but preserving the original on `payload.threadId`). The
// adapter must translate those into `task.progress` events so the
// AgentGroupCard reflects live activity, and must NOT emit the normal
// `item.started` / `item.completed` mappings (which would otherwise pollute
// the parent transcript with the subagent's items).
it.effect("emits task.progress with lastToolName for child-thread item/started", () =>
Effect.gen(function* () {
const { adapter, runtime } = yield* startLifecycleRuntime();
const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe(
Effect.forkChild,
);

// Spawn the subagent so the tracker registers it.
yield* runtime.emit({
id: asEventId("evt-spawn"),
kind: "notification",
provider: "codex",
createdAt: new Date().toISOString(),
method: "item/completed",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
itemId: asItemId("collab_progress"),
payload: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "collabAgentToolCall",
id: "collab_progress",
tool: "spawnAgent",
status: "completed",
senderThreadId: "thread-1",
receiverThreadIds: ["sub-thread-progress"],
agentsStates: { "sub-thread-progress": { status: "running" } },
prompt: "Investigate the cache layer",
model: "gpt-5.4",
},
},
} satisfies ProviderEvent);

// Child-thread item/started for a shell command. The runtime has
// rewritten event.threadId to the parent's, but payload.threadId still
// points at the subagent thread.
yield* runtime.emit({
id: asEventId("evt-child-started"),
kind: "notification",
provider: "codex",
createdAt: new Date().toISOString(),
method: "item/started",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
itemId: asItemId("cmd_child_1"),
payload: {
threadId: "sub-thread-progress",
turnId: "child-turn-1",
item: {
type: "commandExecution",
id: "cmd_child_1",
command: "rg --files",
commandActions: [],
cwd: "/tmp",
status: "inProgress",
},
},
} satisfies ProviderEvent);

const events = Array.from(yield* Fiber.join(eventsFiber));
// Parent's spawn produces item.completed + task.started. Child's
// item/started is suppressed and replaced by exactly one task.progress.
assert.equal(events.length, 3);
assert.equal(events[0]?.type, "item.completed");
assert.equal(events[1]?.type, "task.started");
const progress = events[2];
assert.equal(progress?.type, "task.progress");
if (progress?.type !== "task.progress") return;
assert.equal(progress.payload.taskId, "sub-thread-progress");
assert.equal(progress.payload.description, "Investigate the cache layer");
assert.equal(progress.payload.lastToolName, "Shell");
// No item.started leaked into the parent transcript.
assert.equal(
events.some((e) => e.type === "item.started"),
false,
);
}),
);

it.effect("emits task.progress with summary for child-thread item/completed", () =>
Effect.gen(function* () {
const { adapter, runtime } = yield* startLifecycleRuntime();
const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe(
Effect.forkChild,
);

yield* runtime.emit({
id: asEventId("evt-spawn-2"),
kind: "notification",
provider: "codex",
createdAt: new Date().toISOString(),
method: "item/completed",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
itemId: asItemId("collab_summary"),
payload: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "collabAgentToolCall",
id: "collab_summary",
tool: "spawnAgent",
status: "completed",
senderThreadId: "thread-1",
receiverThreadIds: ["sub-thread-summary"],
agentsStates: { "sub-thread-summary": { status: "running" } },
prompt: "Pick a random file and report it",
model: "gpt-5.4-mini",
},
},
} satisfies ProviderEvent);

yield* runtime.emit({
id: asEventId("evt-child-completed"),
kind: "notification",
provider: "codex",
createdAt: new Date().toISOString(),
method: "item/completed",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
itemId: asItemId("msg_child_1"),
payload: {
threadId: "sub-thread-summary",
turnId: "child-turn-1",
item: {
type: "agentMessage",
id: "msg_child_1",
text: "I'll read apps/server/src/config.ts now.",
},
},
} satisfies ProviderEvent);

const events = Array.from(yield* Fiber.join(eventsFiber));
assert.equal(events.length, 3);
const progress = events[2];
assert.equal(progress?.type, "task.progress");
if (progress?.type !== "task.progress") return;
assert.equal(progress.payload.taskId, "sub-thread-summary");
assert.equal(progress.payload.description, "Pick a random file and report it");
assert.equal(progress.payload.summary, "I'll read apps/server/src/config.ts now.");
// The subagent's assistantMessage must not surface as the parent's own
// item.completed — that's what previously polluted the transcript.
const itemCompletedCount = events.filter((e) => e.type === "item.completed").length;
assert.equal(itemCompletedCount, 1); // only the parent's collabAgentToolCall
}),
);

it.effect("suppresses child-thread delta events to avoid polluting transcript", () =>
Effect.gen(function* () {
const { adapter, runtime } = yield* startLifecycleRuntime();
const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe(
Effect.forkChild,
);

yield* runtime.emit({
id: asEventId("evt-spawn-3"),
kind: "notification",
provider: "codex",
createdAt: new Date().toISOString(),
method: "item/completed",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
itemId: asItemId("collab_delta"),
payload: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "collabAgentToolCall",
id: "collab_delta",
tool: "spawnAgent",
status: "completed",
senderThreadId: "thread-1",
receiverThreadIds: ["sub-thread-delta"],
agentsStates: { "sub-thread-delta": { status: "running" } },
prompt: "Stream some text",
},
},
} satisfies ProviderEvent);

// A child-thread agentMessage delta. We do NOT want this to flow through
// as a content.delta — that would render in the parent's transcript as
// if the parent assistant were speaking the subagent's words.
yield* runtime.emit({
id: asEventId("evt-child-delta"),
kind: "notification",
provider: "codex",
createdAt: new Date().toISOString(),
method: "item/agentMessage/delta",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
itemId: asItemId("msg_child_2"),
textDelta: "Hello from subagent",
payload: {
threadId: "sub-thread-delta",
turnId: "child-turn-1",
itemId: "msg_child_2",
delta: "Hello from subagent",
},
} satisfies ProviderEvent);

const events = Array.from(yield* Fiber.join(eventsFiber));
// Only the parent's spawn item.completed + task.started flow through.
// The child's delta is silently dropped.
assert.equal(events.length, 2);
assert.equal(
events.some((e) => e.type === "content.delta"),
false,
);
}),
);

// Regression guard: `payload.threadId` is the Codex provider's UUID, while
// `event.threadId` is marcode's `ThreadId` (set from `options.threadId`) —
// different namespaces that never match even for the parent's own events.
// Subagent detection MUST go via tracker membership only. A naive
// `payload.threadId !== canonicalThreadId` short-circuit would silently
// drop every parent notification, leaving the UI stuck on "Starting…".
it.effect("does not intercept parent events whose payload.threadId differs from canonical", () =>
Effect.gen(function* () {
const { adapter, runtime } = yield* startLifecycleRuntime();
const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);

// Parent's own assistant message — payload.threadId is the provider's
// UUID, distinct from marcode's "thread-1". No spawnAgent ever fired,
// so the tracker is empty; this MUST fall through to normal mapping.
yield* runtime.emit({
id: asEventId("evt-parent-msg"),
kind: "notification",
provider: "codex",
createdAt: new Date().toISOString(),
method: "item/completed",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
itemId: asItemId("parent_msg"),
payload: {
threadId: "0197abcd-codex-provider-uuid",
turnId: "turn-1",
item: {
type: "agentMessage",
id: "parent_msg",
text: "I read the file.",
},
},
} satisfies ProviderEvent);

const firstEvent = yield* Fiber.join(firstEventFiber);
assert.equal(firstEvent._tag, "Some");
if (firstEvent._tag !== "Some") return;
assert.equal(firstEvent.value.type, "item.completed");
if (firstEvent.value.type !== "item.completed") return;
assert.equal(firstEvent.value.itemId, "parent_msg");
}),
);
});

const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory();
Expand Down
Loading
Loading