diff --git a/.changeset/restore-reconciles-turn-outcome.md b/.changeset/restore-reconciles-turn-outcome.md new file mode 100644 index 000000000..304f9079f --- /dev/null +++ b/.changeset/restore-reconciles-turn-outcome.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix a resumed session showing a stale "manually stopped" or failed state when its last turn had actually completed. diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts index f229e32cc..5ecaee168 100644 --- a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts @@ -129,6 +129,11 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM this.write(undefined, { touchUpdatedAt: false }); return; } + const replayed = replayedOutcome(lastEnded.reason); + if (replayed !== undefined && replayed !== this.lastPersisted) { + this.write(replayed, { touchUpdatedAt: false, turnId: lastEnded.turnId }); + return; + } if (this.lastPersistedTurnId === undefined) this.lastPersistedTurnId = lastEnded.turnId; } @@ -164,3 +169,9 @@ registerScopedService( ScopeActivation.OnScopeCreated, 'sessionActivity', ); + +function replayedOutcome(reason: TurnEnded['reason']): SessionTurnOutcome | undefined { + if (reason === 'completed') return 'completed'; + if (reason === 'failed' || reason === 'blocked') return 'failed'; + return undefined; +} diff --git a/packages/agent-core-v2/test/session/sessionActivity/sessionOutcomeMirror.test.ts b/packages/agent-core-v2/test/session/sessionActivity/sessionOutcomeMirror.test.ts index 5698ef8b2..c6d66b4ed 100644 --- a/packages/agent-core-v2/test/session/sessionActivity/sessionOutcomeMirror.test.ts +++ b/packages/agent-core-v2/test/session/sessionActivity/sessionOutcomeMirror.test.ts @@ -371,6 +371,79 @@ describe('SessionOutcomeMirror (Session scope)', () => { expect(writes).toEqual([]); }); + const restoredSession = ( + id: string, + persisted: SessionMeta['lastTurnReason'], + lastEnded: FakeAgentLifecycle['lastEnded'], + ) => { + const scope = host.child(LifecycleScope.Session, id, [ + stubPair(ISessionMetadata, { + read: async () => ({ lastTurnReason: persisted }) as SessionMeta, + update: async ( + patch: { lastTurnReason?: SessionMeta['lastTurnReason'] }, + uopts?: { touchUpdatedAt?: boolean }, + ) => { + writes.push(patch.lastTurnReason); + touches.push(uopts?.touchUpdatedAt !== false); + }, + } as unknown as ISessionMetadata), + ]); + const scopeLifecycle = scope.accessor.get(IAgentLifecycleService) as unknown as FakeAgentLifecycle; + scope.accessor.get(ISessionOutcomeMirror); + scopeLifecycle.addMain(); + scopeLifecycle.lastEnded = lastEnded; + return scopeLifecycle; + }; + + it('replaces a stale persisted outcome with the replayed completed turn', async () => { + const restored = restoredSession('session-restored-completed', 'cancelled', { + turnId: 3, + reason: 'completed', + durationMs: 5, + }); + await tick(); + for (const hook of restored.restoreHooks) await hook(undefined, async () => {}); + expect(writes).toEqual(['completed']); + expect(touches).toEqual([false]); + }); + + it('maps a replayed blocked turn onto the persisted failed outcome', async () => { + const restored = restoredSession('session-restored-blocked', 'completed', { + turnId: 3, + reason: 'blocked', + durationMs: 5, + }); + await tick(); + for (const hook of restored.restoreHooks) await hook(undefined, async () => {}); + expect(writes).toEqual(['failed']); + expect(touches).toEqual([false]); + }); + + it('keeps the persisted outcome when the replayed turn was cancelled', async () => { + const restored = restoredSession('session-restored-cancelled', 'completed', { + turnId: 3, + reason: 'cancelled', + durationMs: 5, + }); + await tick(); + for (const hook of restored.restoreHooks) await hook(undefined, async () => {}); + expect(writes).toEqual([]); + }); + + it('tracks the replayed turn for the undo range after a restore reconcile', async () => { + const restored = restoredSession('session-restored-undo', 'cancelled', { + turnId: 3, + reason: 'completed', + durationMs: 5, + }); + await tick(); + for (const hook of restored.restoreHooks) await hook(undefined, async () => {}); + restored.bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 4 })); + expect(writes).toEqual(['completed']); + restored.bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 3 })); + expect(writes).toEqual(['completed', undefined]); + }); + it('reattaches when the main agent is disposed and recreated', async () => { lifecycle.addMain(); await tick();