Skip to content

Commit 4782fbb

Browse files
authored
Merge pull request #72 from tyulyukov/marcode/sync-upstream-2026-04-28
marcode: port upstream WS-reconnect + smoke node fix (PR #72)
2 parents 7f04a4a + 7950e93 commit 4782fbb

12 files changed

Lines changed: 739 additions & 18 deletions

File tree

UPSTREAM_DIVERGENCE.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,26 @@ Sections are ordered by action:
3737

3838
## Ported in the current cycle
3939

40-
**Cycle:** 2026-04-24 · Baseline before cycle: `7c430aece` · Baseline after cycle: `ececcdcb1`
40+
**Cycle:** 2026-04-28 · Baseline before cycle: `7f04a4a11` · Baseline after cycle: `ef574febf`
41+
42+
### PR #72 — upstream sync (2026-04-28)
43+
44+
| Upstream | Subject | New SHA |
45+
| ------------------------------------------------------ | ---------------------------------------------------------- | ----------- |
46+
| [#2364](https://github.com/pingdotgg/t3code/pull/2364) | fix(release): use configured node for smoke manifest merge | `2f1e3cc3c` |
47+
| [#2372](https://github.com/pingdotgg/t3code/pull/2372) | Ignore stale WebSocket lifecycle events after reconnect | `b408a6857` |
48+
49+
**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).
50+
51+
**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.
52+
53+
**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.
54+
55+
---
56+
57+
## Previous cycle: 2026-04-24
58+
59+
**Baseline before cycle:** `7c430aece` · **Baseline after cycle:** `ececcdcb1`
4160

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

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

188207
## Pending real work
189208

190-
_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.
209+
_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.
191210

192211
---
193212

apps/server/src/provider/Layers/CodexAdapter.test.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1491,6 +1491,268 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
14911491
assert.equal(events[0]?.type, "item.completed");
14921492
}),
14931493
);
1494+
1495+
// Once a subagent has been spawned, the Codex runtime forwards child-thread
1496+
// notifications through the parent's stream (rewriting `event.threadId` to
1497+
// the parent's, but preserving the original on `payload.threadId`). The
1498+
// adapter must translate those into `task.progress` events so the
1499+
// AgentGroupCard reflects live activity, and must NOT emit the normal
1500+
// `item.started` / `item.completed` mappings (which would otherwise pollute
1501+
// the parent transcript with the subagent's items).
1502+
it.effect("emits task.progress with lastToolName for child-thread item/started", () =>
1503+
Effect.gen(function* () {
1504+
const { adapter, runtime } = yield* startLifecycleRuntime();
1505+
const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe(
1506+
Effect.forkChild,
1507+
);
1508+
1509+
// Spawn the subagent so the tracker registers it.
1510+
yield* runtime.emit({
1511+
id: asEventId("evt-spawn"),
1512+
kind: "notification",
1513+
provider: "codex",
1514+
createdAt: new Date().toISOString(),
1515+
method: "item/completed",
1516+
threadId: asThreadId("thread-1"),
1517+
turnId: asTurnId("turn-1"),
1518+
itemId: asItemId("collab_progress"),
1519+
payload: {
1520+
threadId: "thread-1",
1521+
turnId: "turn-1",
1522+
item: {
1523+
type: "collabAgentToolCall",
1524+
id: "collab_progress",
1525+
tool: "spawnAgent",
1526+
status: "completed",
1527+
senderThreadId: "thread-1",
1528+
receiverThreadIds: ["sub-thread-progress"],
1529+
agentsStates: { "sub-thread-progress": { status: "running" } },
1530+
prompt: "Investigate the cache layer",
1531+
model: "gpt-5.4",
1532+
},
1533+
},
1534+
} satisfies ProviderEvent);
1535+
1536+
// Child-thread item/started for a shell command. The runtime has
1537+
// rewritten event.threadId to the parent's, but payload.threadId still
1538+
// points at the subagent thread.
1539+
yield* runtime.emit({
1540+
id: asEventId("evt-child-started"),
1541+
kind: "notification",
1542+
provider: "codex",
1543+
createdAt: new Date().toISOString(),
1544+
method: "item/started",
1545+
threadId: asThreadId("thread-1"),
1546+
turnId: asTurnId("turn-1"),
1547+
itemId: asItemId("cmd_child_1"),
1548+
payload: {
1549+
threadId: "sub-thread-progress",
1550+
turnId: "child-turn-1",
1551+
item: {
1552+
type: "commandExecution",
1553+
id: "cmd_child_1",
1554+
command: "rg --files",
1555+
commandActions: [],
1556+
cwd: "/tmp",
1557+
status: "inProgress",
1558+
},
1559+
},
1560+
} satisfies ProviderEvent);
1561+
1562+
const events = Array.from(yield* Fiber.join(eventsFiber));
1563+
// Parent's spawn produces item.completed + task.started. Child's
1564+
// item/started is suppressed and replaced by exactly one task.progress.
1565+
assert.equal(events.length, 3);
1566+
assert.equal(events[0]?.type, "item.completed");
1567+
assert.equal(events[1]?.type, "task.started");
1568+
const progress = events[2];
1569+
assert.equal(progress?.type, "task.progress");
1570+
if (progress?.type !== "task.progress") return;
1571+
assert.equal(progress.payload.taskId, "sub-thread-progress");
1572+
assert.equal(progress.payload.description, "Investigate the cache layer");
1573+
assert.equal(progress.payload.lastToolName, "Shell");
1574+
// No item.started leaked into the parent transcript.
1575+
assert.equal(
1576+
events.some((e) => e.type === "item.started"),
1577+
false,
1578+
);
1579+
}),
1580+
);
1581+
1582+
it.effect("emits task.progress with summary for child-thread item/completed", () =>
1583+
Effect.gen(function* () {
1584+
const { adapter, runtime } = yield* startLifecycleRuntime();
1585+
const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe(
1586+
Effect.forkChild,
1587+
);
1588+
1589+
yield* runtime.emit({
1590+
id: asEventId("evt-spawn-2"),
1591+
kind: "notification",
1592+
provider: "codex",
1593+
createdAt: new Date().toISOString(),
1594+
method: "item/completed",
1595+
threadId: asThreadId("thread-1"),
1596+
turnId: asTurnId("turn-1"),
1597+
itemId: asItemId("collab_summary"),
1598+
payload: {
1599+
threadId: "thread-1",
1600+
turnId: "turn-1",
1601+
item: {
1602+
type: "collabAgentToolCall",
1603+
id: "collab_summary",
1604+
tool: "spawnAgent",
1605+
status: "completed",
1606+
senderThreadId: "thread-1",
1607+
receiverThreadIds: ["sub-thread-summary"],
1608+
agentsStates: { "sub-thread-summary": { status: "running" } },
1609+
prompt: "Pick a random file and report it",
1610+
model: "gpt-5.4-mini",
1611+
},
1612+
},
1613+
} satisfies ProviderEvent);
1614+
1615+
yield* runtime.emit({
1616+
id: asEventId("evt-child-completed"),
1617+
kind: "notification",
1618+
provider: "codex",
1619+
createdAt: new Date().toISOString(),
1620+
method: "item/completed",
1621+
threadId: asThreadId("thread-1"),
1622+
turnId: asTurnId("turn-1"),
1623+
itemId: asItemId("msg_child_1"),
1624+
payload: {
1625+
threadId: "sub-thread-summary",
1626+
turnId: "child-turn-1",
1627+
item: {
1628+
type: "agentMessage",
1629+
id: "msg_child_1",
1630+
text: "I'll read apps/server/src/config.ts now.",
1631+
},
1632+
},
1633+
} satisfies ProviderEvent);
1634+
1635+
const events = Array.from(yield* Fiber.join(eventsFiber));
1636+
assert.equal(events.length, 3);
1637+
const progress = events[2];
1638+
assert.equal(progress?.type, "task.progress");
1639+
if (progress?.type !== "task.progress") return;
1640+
assert.equal(progress.payload.taskId, "sub-thread-summary");
1641+
assert.equal(progress.payload.description, "Pick a random file and report it");
1642+
assert.equal(progress.payload.summary, "I'll read apps/server/src/config.ts now.");
1643+
// The subagent's assistantMessage must not surface as the parent's own
1644+
// item.completed — that's what previously polluted the transcript.
1645+
const itemCompletedCount = events.filter((e) => e.type === "item.completed").length;
1646+
assert.equal(itemCompletedCount, 1); // only the parent's collabAgentToolCall
1647+
}),
1648+
);
1649+
1650+
it.effect("suppresses child-thread delta events to avoid polluting transcript", () =>
1651+
Effect.gen(function* () {
1652+
const { adapter, runtime } = yield* startLifecycleRuntime();
1653+
const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe(
1654+
Effect.forkChild,
1655+
);
1656+
1657+
yield* runtime.emit({
1658+
id: asEventId("evt-spawn-3"),
1659+
kind: "notification",
1660+
provider: "codex",
1661+
createdAt: new Date().toISOString(),
1662+
method: "item/completed",
1663+
threadId: asThreadId("thread-1"),
1664+
turnId: asTurnId("turn-1"),
1665+
itemId: asItemId("collab_delta"),
1666+
payload: {
1667+
threadId: "thread-1",
1668+
turnId: "turn-1",
1669+
item: {
1670+
type: "collabAgentToolCall",
1671+
id: "collab_delta",
1672+
tool: "spawnAgent",
1673+
status: "completed",
1674+
senderThreadId: "thread-1",
1675+
receiverThreadIds: ["sub-thread-delta"],
1676+
agentsStates: { "sub-thread-delta": { status: "running" } },
1677+
prompt: "Stream some text",
1678+
},
1679+
},
1680+
} satisfies ProviderEvent);
1681+
1682+
// A child-thread agentMessage delta. We do NOT want this to flow through
1683+
// as a content.delta — that would render in the parent's transcript as
1684+
// if the parent assistant were speaking the subagent's words.
1685+
yield* runtime.emit({
1686+
id: asEventId("evt-child-delta"),
1687+
kind: "notification",
1688+
provider: "codex",
1689+
createdAt: new Date().toISOString(),
1690+
method: "item/agentMessage/delta",
1691+
threadId: asThreadId("thread-1"),
1692+
turnId: asTurnId("turn-1"),
1693+
itemId: asItemId("msg_child_2"),
1694+
textDelta: "Hello from subagent",
1695+
payload: {
1696+
threadId: "sub-thread-delta",
1697+
turnId: "child-turn-1",
1698+
itemId: "msg_child_2",
1699+
delta: "Hello from subagent",
1700+
},
1701+
} satisfies ProviderEvent);
1702+
1703+
const events = Array.from(yield* Fiber.join(eventsFiber));
1704+
// Only the parent's spawn item.completed + task.started flow through.
1705+
// The child's delta is silently dropped.
1706+
assert.equal(events.length, 2);
1707+
assert.equal(
1708+
events.some((e) => e.type === "content.delta"),
1709+
false,
1710+
);
1711+
}),
1712+
);
1713+
1714+
// Regression guard: `payload.threadId` is the Codex provider's UUID, while
1715+
// `event.threadId` is marcode's `ThreadId` (set from `options.threadId`) —
1716+
// different namespaces that never match even for the parent's own events.
1717+
// Subagent detection MUST go via tracker membership only. A naive
1718+
// `payload.threadId !== canonicalThreadId` short-circuit would silently
1719+
// drop every parent notification, leaving the UI stuck on "Starting…".
1720+
it.effect("does not intercept parent events whose payload.threadId differs from canonical", () =>
1721+
Effect.gen(function* () {
1722+
const { adapter, runtime } = yield* startLifecycleRuntime();
1723+
const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
1724+
1725+
// Parent's own assistant message — payload.threadId is the provider's
1726+
// UUID, distinct from marcode's "thread-1". No spawnAgent ever fired,
1727+
// so the tracker is empty; this MUST fall through to normal mapping.
1728+
yield* runtime.emit({
1729+
id: asEventId("evt-parent-msg"),
1730+
kind: "notification",
1731+
provider: "codex",
1732+
createdAt: new Date().toISOString(),
1733+
method: "item/completed",
1734+
threadId: asThreadId("thread-1"),
1735+
turnId: asTurnId("turn-1"),
1736+
itemId: asItemId("parent_msg"),
1737+
payload: {
1738+
threadId: "0197abcd-codex-provider-uuid",
1739+
turnId: "turn-1",
1740+
item: {
1741+
type: "agentMessage",
1742+
id: "parent_msg",
1743+
text: "I read the file.",
1744+
},
1745+
},
1746+
} satisfies ProviderEvent);
1747+
1748+
const firstEvent = yield* Fiber.join(firstEventFiber);
1749+
assert.equal(firstEvent._tag, "Some");
1750+
if (firstEvent._tag !== "Some") return;
1751+
assert.equal(firstEvent.value.type, "item.completed");
1752+
if (firstEvent.value.type !== "item.completed") return;
1753+
assert.equal(firstEvent.value.itemId, "parent_msg");
1754+
}),
1755+
);
14941756
});
14951757

14961758
const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory();

0 commit comments

Comments
 (0)