Skip to content

Commit 81f1070

Browse files
authored
fix(agent-gateway): seed agent phase when a transcript attaches mid-turn (#365)
## Requirement or Bug Resolve #350 ## Bug Reproduction Steps In `transcriptContract.e2e.test.ts` S3, add `await new Promise((r) => setTimeout(r, 3000));` after `submitPrompt`. It then fails every time with `expect(mid.meta.agent).toBeDefined()`. Without the sleep it fails about 1 run in 4, and more often under load. ## Root Cause The transcript store binds to the event bus lazily, on the first transcript request. `meta.agent.phase` comes only from the projector's phase merge, and that merge runs only when a new bus event arrives. On a late attach, `subscribeAgent` (`coreBinding.ts`) restores the running turn with `seedActiveTurn`, but it does not restore the phase. `seedPendingInteractions` then adds the approval. The turn is blocked on that approval, so no further event comes and `meta.agent` stays undefined. In the product, a client that opens or reloads a session while an approval is pending shows the approval with no agent status. Fundamental fix. ## Code Changes - `coreEventMap.ts`: new `AgentTranscriptProjector.seedPhase()`. It recomputes the phase from the activity snapshot and the pending approvals, and returns one `meta.merge`. The tracker set-up moves to `tracker()`, which `phaseFor` reuses. - `coreBinding.ts`: when attaching to a turn that is already running, apply `seedPhase()` after `seedActiveTurn`. Idle and new agents are not touched. A later phase event with the same value is deduplicated by the tracker's `lastPhaseKey`. - Test: `transcript.test.ts` checks that `seedPhase()` gives `awaiting_approval` for a turn blocked on an approval. Not in scope: `meta.agent.model` and `meta.agent.permission` on late attach. S3 needs only the phase. ## Behavior Changes and Affected Users | Behavior | Before | After | Who relies on the old behavior | Escape hatch | | --- | --- | --- | --- | --- | | Transcript first opened while a turn is running | `meta.agent` missing until the next phase event (never, if the turn is blocked on an approval) | `meta.agent.phase` is set immediately | None. Clients already handle `meta.agent.phase` from live events | None needed | Consumers: web, desktop, and inspector clients that read `meta.agent.phase`. The wire shape is unchanged. Verification: forced-late S3 fails 2/2 without the fix and passes 2/2 with it. Full agent-gateway suite: 1408/1408. No quarantine for S3 exists in the repo, so there is nothing to remove. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue (external PRs: issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. - [x] The behavior-change table above is complete, and every removed behavior or flipped default is named in the changeset and either has an escape hatch or was explicitly approved by a maintainer in this PR. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update.
1 parent ecc2c3b commit 81f1070

4 files changed

Lines changed: 36 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Clients that open a session while a tool approval is pending now show the agent's status instead of none.

‎packages/agent-gateway/src/services/transcript/coreBinding.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export function bindSessionTranscript(
155155
if (loopStatus?.state === 'running' && loopStatus.activeTurnId !== undefined) {
156156
const promptId = loopStatus.activePromptId;
157157
projector.seedActiveTurn({ turnId: loopStatus.activeTurnId, promptId });
158+
applyOps(handle.id, projector.seedPhase());
158159
}
159160
const list = agentDisposables.get(handle.id) ?? [];
160161
list.push(busD);

‎packages/agent-gateway/src/services/transcript/coreEventMap.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,15 +357,25 @@ export class AgentTranscriptProjector {
357357
return [...ops, { op: 'meta.merge', meta: { agent: { phase } } }];
358358
}
359359

360-
private phaseFor(event: ProjectorBusEvent): ReturnType<typeof toLegacyPhase> {
360+
seedPhase(): TranscriptOperation[] {
361+
const phase = this.tracker()?.recompute();
362+
return phase === undefined ? [] : [{ op: 'meta.merge', meta: { agent: { phase } } }];
363+
}
364+
365+
private tracker(): LegacyActivityTracker | undefined {
361366
if (this.lookups?.activitySnapshot === undefined || this.lookups.pendingApprovals === undefined) {
362367
return undefined;
363368
}
364369
this.activityTracker ??= new LegacyActivityTracker(
365370
this.lookups.activitySnapshot,
366371
this.lookups.pendingApprovals,
367372
);
368-
return phaseFromDomainEvent(this.activityTracker, event);
373+
return this.activityTracker;
374+
}
375+
376+
private phaseFor(event: ProjectorBusEvent): ReturnType<typeof toLegacyPhase> {
377+
const tracker = this.tracker();
378+
return tracker === undefined ? undefined : phaseFromDomainEvent(tracker, event);
369379
}
370380

371381
private mapEvent(event: ProjectorBusEvent): TranscriptOperation[] {

‎packages/agent-gateway/test/services/transcript.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1597,6 +1597,24 @@ describe('AgentTranscriptProjector', () => {
15971597
expect(tx.getMeta().agent).toMatchObject({ model: 'k3', thinkingEffort: 'high' });
15981598
});
15991599

1600+
it('seeds meta.agent.phase when attaching to a turn blocked on an approval', () => {
1601+
const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, {
1602+
activitySnapshot: () => ({
1603+
turn: { turnId: 1, phase: 'running', step: 1, ending: false, activeToolCalls: [], since: 1000 },
1604+
}),
1605+
pendingApprovals: () => [{ approvalId: 'ap1', toolCallId: 'c1', since: 1500 }],
1606+
});
1607+
const tx = new AgentTranscript('main');
1608+
1609+
tx.apply(projector.seedPhase());
1610+
1611+
expect(tx.getMeta().agent?.phase).toMatchObject({
1612+
kind: 'awaiting_approval',
1613+
turnId: 1,
1614+
approval: { approvalId: 'ap1', toolCallId: 'c1' },
1615+
});
1616+
});
1617+
16001618
it('maps domain events into meta.agent.phase', () => {
16011619
let snapshot: AgentActivitySnapshot = {};
16021620
let approvals: readonly LegacyActivityApproval[] = [];

0 commit comments

Comments
 (0)