Skip to content

Commit 1e73bc1

Browse files
authored
Merge branch 'main' into fix/sidebar-banner-assets
2 parents 94f62b9 + e4ed37f commit 1e73bc1

3 files changed

Lines changed: 89 additions & 0 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+
Fix a resumed session showing a stale "manually stopped" or failed state when its last turn had actually completed.

packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM
129129
this.write(undefined, { touchUpdatedAt: false });
130130
return;
131131
}
132+
const replayed = replayedOutcome(lastEnded.reason);
133+
if (replayed !== undefined && replayed !== this.lastPersisted) {
134+
this.write(replayed, { touchUpdatedAt: false, turnId: lastEnded.turnId });
135+
return;
136+
}
132137
if (this.lastPersistedTurnId === undefined) this.lastPersistedTurnId = lastEnded.turnId;
133138
}
134139

@@ -164,3 +169,9 @@ registerScopedService(
164169
ScopeActivation.OnScopeCreated,
165170
'sessionActivity',
166171
);
172+
173+
function replayedOutcome(reason: TurnEnded['reason']): SessionTurnOutcome | undefined {
174+
if (reason === 'completed') return 'completed';
175+
if (reason === 'failed' || reason === 'blocked') return 'failed';
176+
return undefined;
177+
}

packages/agent-core-v2/test/session/sessionActivity/sessionOutcomeMirror.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,79 @@ describe('SessionOutcomeMirror (Session scope)', () => {
371371
expect(writes).toEqual([]);
372372
});
373373

374+
const restoredSession = (
375+
id: string,
376+
persisted: SessionMeta['lastTurnReason'],
377+
lastEnded: FakeAgentLifecycle['lastEnded'],
378+
) => {
379+
const scope = host.child(LifecycleScope.Session, id, [
380+
stubPair(ISessionMetadata, {
381+
read: async () => ({ lastTurnReason: persisted }) as SessionMeta,
382+
update: async (
383+
patch: { lastTurnReason?: SessionMeta['lastTurnReason'] },
384+
uopts?: { touchUpdatedAt?: boolean },
385+
) => {
386+
writes.push(patch.lastTurnReason);
387+
touches.push(uopts?.touchUpdatedAt !== false);
388+
},
389+
} as unknown as ISessionMetadata),
390+
]);
391+
const scopeLifecycle = scope.accessor.get(IAgentLifecycleService) as unknown as FakeAgentLifecycle;
392+
scope.accessor.get(ISessionOutcomeMirror);
393+
scopeLifecycle.addMain();
394+
scopeLifecycle.lastEnded = lastEnded;
395+
return scopeLifecycle;
396+
};
397+
398+
it('replaces a stale persisted outcome with the replayed completed turn', async () => {
399+
const restored = restoredSession('session-restored-completed', 'cancelled', {
400+
turnId: 3,
401+
reason: 'completed',
402+
durationMs: 5,
403+
});
404+
await tick();
405+
for (const hook of restored.restoreHooks) await hook(undefined, async () => {});
406+
expect(writes).toEqual(['completed']);
407+
expect(touches).toEqual([false]);
408+
});
409+
410+
it('maps a replayed blocked turn onto the persisted failed outcome', async () => {
411+
const restored = restoredSession('session-restored-blocked', 'completed', {
412+
turnId: 3,
413+
reason: 'blocked',
414+
durationMs: 5,
415+
});
416+
await tick();
417+
for (const hook of restored.restoreHooks) await hook(undefined, async () => {});
418+
expect(writes).toEqual(['failed']);
419+
expect(touches).toEqual([false]);
420+
});
421+
422+
it('keeps the persisted outcome when the replayed turn was cancelled', async () => {
423+
const restored = restoredSession('session-restored-cancelled', 'completed', {
424+
turnId: 3,
425+
reason: 'cancelled',
426+
durationMs: 5,
427+
});
428+
await tick();
429+
for (const hook of restored.restoreHooks) await hook(undefined, async () => {});
430+
expect(writes).toEqual([]);
431+
});
432+
433+
it('tracks the replayed turn for the undo range after a restore reconcile', async () => {
434+
const restored = restoredSession('session-restored-undo', 'cancelled', {
435+
turnId: 3,
436+
reason: 'completed',
437+
durationMs: 5,
438+
});
439+
await tick();
440+
for (const hook of restored.restoreHooks) await hook(undefined, async () => {});
441+
restored.bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 4 }));
442+
expect(writes).toEqual(['completed']);
443+
restored.bus.publish(new ContextUndone({ agentId: 'main', turns: 1, fromTurnId: 3 }));
444+
expect(writes).toEqual(['completed', undefined]);
445+
});
446+
374447
it('reattaches when the main agent is disposed and recreated', async () => {
375448
lifecycle.addMain();
376449
await tick();

0 commit comments

Comments
 (0)