Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a9c7f74
feat(agent-core-v2): persist the last turn outcome into session metad…
wbxl2000 Aug 5, 2026
a90c83c
fix(klient): carry lastTurnReason/lastTurnOutcome in the validated co…
wbxl2000 Aug 5, 2026
b7e2c36
fix(agent-core-v2): persist user-cancelled outcomes, never teardown a…
wbxl2000 Aug 5, 2026
5e2561b
fix(kap-server): only fall back to the persisted outcome for cold ses…
wbxl2000 Aug 5, 2026
a6efba8
docs(agent-core-v2): keep the outcome-recorder header at role level
wbxl2000 Aug 5, 2026
c442515
fix(agent-core-v2): settle turn outcomes on turn start and drain meta…
wbxl2000 Aug 5, 2026
241c41f
fix(agent-core-v2): track the metadata dispose flag locally
wbxl2000 Aug 5, 2026
532037c
fix(kap-server): drain session metadata writes before the mirror and …
wbxl2000 Aug 5, 2026
21559fa
fix(agent-core-v2): reattach the recorder when the main agent is recr…
wbxl2000 Aug 5, 2026
5ef0c87
test(agent-core-v2): resolve the recorder through the scoped DI harness
wbxl2000 Aug 5, 2026
c62d190
fix(agent-core-v2): unbreak CI — iterable Promise.all and the debug c…
wbxl2000 Aug 5, 2026
345c761
fix(kap-server): surface persisted failures on the v2 session status
wbxl2000 Aug 6, 2026
e83a171
refactor(agent-core-v2): name the persisted field lastTurnReason
wbxl2000 Aug 6, 2026
c9ba5a1
fix(agent-core-v2): drain pending metadata writes before session tear…
wbxl2000 Aug 6, 2026
69b35ae
fix(node-sdk): carry lastTurnReason through the SDK session summary
wbxl2000 Aug 6, 2026
f4dc3bb
fix(node-sdk): populate lastTurnReason on live SDK summaries
wbxl2000 Aug 6, 2026
a50d776
fix(agent-core-v2): carry the last turn outcome across session forks
wbxl2000 Aug 6, 2026
3902d01
fix(agent-core-v2): settle pending outcome writes before reading a fo…
wbxl2000 Aug 6, 2026
6dc33a1
fix(agent-core-v2): backfill restored outcomes into the session metadata
wbxl2000 Aug 6, 2026
87f0568
fix(agent-core-v2): never backfill restored cancellations
wbxl2000 Aug 6, 2026
6d24140
refactor(agent-core-v2): rename the outcome recorder to outcome mirror
wbxl2000 Aug 6, 2026
f02041c
fix(agent-core-v2): backfill without bumping recency; header-only com…
wbxl2000 Aug 6, 2026
c3c4507
fix(node-sdk): read the live outcome for resumed session summaries
wbxl2000 Aug 6, 2026
977e533
fix(agent-core-v2): confine the outcome backfill to pure resumes
wbxl2000 Aug 6, 2026
109cfed
fix(agent-core-v2): drain the session-index mirror before session tea…
wbxl2000 Aug 6, 2026
883ba17
docs(agent-core-v2): fold the metadata drain contract into the file h…
wbxl2000 Aug 6, 2026
a904ce3
chore: include the SDK package in the changeset; fold the drain note …
wbxl2000 Aug 6, 2026
faf73c7
fix(agent-core-v2): backfill restored cancellations too, quietly
wbxl2000 Aug 6, 2026
19634c7
fix(node-sdk): prefer the live outcome over the index in SDK listings
wbxl2000 Aug 6, 2026
8c511e6
fix(node-sdk): never read the metadata outcome for a live session
wbxl2000 Aug 6, 2026
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
5 changes: 5 additions & 0 deletions .changeset/cold-session-last-turn-outcome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
Comment thread
wbxl2000 marked this conversation as resolved.
---

Session listings keep how the last turn ended (completed, cancelled, or failed) across server restarts, so clients can mark previously failed sessions before they are opened.
1 change: 1 addition & 0 deletions packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ export interface SessionStateSnapshot {
readonly swarmItem?: string;
}>>;
readonly custom?: Record<string, unknown>;
readonly lastTurnOutcome?: 'completed' | 'cancelled' | 'failed';
} | undefined;
// src/session/sessionSkillCatalog/skillCatalogService.ts
'sessionSkillCatalog.contributions': Map<string, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface SessionSummary {
readonly updatedAt: number;
readonly archived: boolean;
readonly custom?: Record<string, unknown>;
readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed';
Comment thread
wbxl2000 marked this conversation as resolved.
Comment thread
wbxl2000 marked this conversation as resolved.
}

export interface SessionListQuery {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export function parseTime(value: unknown): number {
return 0;
}

export function parseTurnOutcome(value: unknown): 'completed' | 'cancelled' | 'failed' | undefined {
return value === 'completed' || value === 'cancelled' || value === 'failed' ? value : undefined;
}

export function recoverCwd(meta: Record<string, unknown>): string | undefined {
if (typeof meta['cwd'] === 'string' && meta['cwd'].length > 0) return meta['cwd'];
if (typeof meta['workDir'] === 'string' && meta['workDir'].length > 0) {
Expand All @@ -63,6 +67,7 @@ export function buildSessionSummary(fields: {
updatedAt: number;
archived: boolean;
custom?: Record<string, unknown>;
lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}): SessionSummary {
return {
id: fields.id,
Expand All @@ -74,6 +79,7 @@ export function buildSessionSummary(fields: {
updatedAt: fields.updatedAt,
archived: fields.archived,
custom: fields.custom,
lastTurnReason: fields.lastTurnReason,
};
}

Expand Down Expand Up @@ -102,6 +108,7 @@ export function summaryEquals(a: SessionSummary, b: SessionSummary): boolean {
a.createdAt === b.createdAt &&
a.updatedAt === b.updatedAt &&
a.archived === b.archived &&
a.lastTurnReason === b.lastTurnReason &&
JSON.stringify(a.custom) === JSON.stringify(b.custom)
);
}
Expand Down Expand Up @@ -153,6 +160,7 @@ export async function readSessionSummary(
updatedAt: parseTime(meta['updatedAt']),
archived: meta['archived'] === true,
custom,
lastTurnReason: parseTurnOutcome(meta['lastTurnOutcome']),
});
}

Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export * from '#/session/sessionMetadata/sessionMetadata';
export * from '#/session/sessionMetadata/sessionMetadataService';
export * from '#/session/sessionActivity/sessionActivity';
export * from '#/session/sessionActivity/sessionActivityService';
export * from '#/session/sessionActivity/sessionOutcomeRecorder';
export * from '#/session/sessionActivity/sessionOutcomeRecorderService';
export * from '#/session/sessionToolPolicy/sessionToolPolicy';
export * from '#/session/sessionToolPolicy/sessionToolPolicyService';
export * from '#/app/config/config';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* `sessionActivity` domain — `ISessionOutcomeRecorder` contract: persist the
* latest main-turn outcome into durable session metadata.
*
* The activity aggregate's `lastTurnReason` is live fold state, rebuilt per
* process; this recorder is the write side that lands terminal outcomes in
* the session's metadata document, so the session index (and therefore cold
* listings after a restart) keep reporting them. Session-scoped — one
* instance per session.
*/

import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';

export interface ISessionOutcomeRecorder {
readonly _serviceBrand: undefined;
}

export const ISessionOutcomeRecorder: ServiceIdentifier<ISessionOutcomeRecorder> =
createDecorator<ISessionOutcomeRecorder>('sessionOutcomeRecorder');
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* `sessionActivity` domain — `ISessionOutcomeRecorder` implementation.
*
* Persists the main agent's terminal turn outcomes through `ISessionMetadata`
* (observed via `agentLifecycle` and the main agent's `eventBus`), so the
* session index keeps reporting them across restarts. Programmatic aborts —
* including scope-teardown cancels — are deliberately not persisted. Bound at
* Session scope.
*/

import { Disposable, type IDisposable } from '#/_base/di/lifecycle';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IEventBus } from '#/app/event/eventBus';
import {
IAgentLifecycleService,
MAIN_AGENT_ID,
} from '#/session/agentLifecycle/agentLifecycle';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';

import type { SessionTurnOutcome } from './sessionActivity';
import { ISessionOutcomeRecorder } from './sessionOutcomeRecorder';

export class SessionOutcomeRecorder extends Disposable implements ISessionOutcomeRecorder {
declare readonly _serviceBrand: undefined;

private lastPersisted: SessionTurnOutcome | undefined;
private mainSubscription: IDisposable | undefined;

constructor(
@IAgentLifecycleService private readonly agents: IAgentLifecycleService,
@ISessionMetadata private readonly metadata: ISessionMetadata,
) {
super();
this.attachMain();
this._register(this.agents.onDidCreate((handle) => {
if (handle.id === MAIN_AGENT_ID) this.attachMain();
}));
this._register({
dispose: () => {
this.mainSubscription?.dispose();
this.mainSubscription = undefined;
},
});
}

private attachMain(): void {
if (this.mainSubscription !== undefined) return;
const bus = this.agents.get(MAIN_AGENT_ID)?.accessor.get(IEventBus) as IEventBus | undefined;
if (bus === undefined) return;
this.mainSubscription = bus.subscribe('turn.ended', (event) => {
Comment thread
wbxl2000 marked this conversation as resolved.
Outdated
if (event.type !== 'turn.ended') return;
const reason = (event as { reason?: unknown }).reason;
const interruptReason = (event as { interruptReason?: unknown }).interruptReason;
if (reason === 'completed') {
this.persist('completed');
return;
}
if (reason === 'failed' || reason === 'blocked') {
this.persist('failed');
return;
}
if (reason === 'cancelled' && interruptReason === 'user_cancelled') {
this.persist('cancelled');
}
});
}

private persist(outcome: SessionTurnOutcome): void {
if (outcome === this.lastPersisted) return;
this.lastPersisted = outcome;
void this.metadata
.update({ lastTurnOutcome: outcome })
.catch(() => {});
}
}

registerScopedService(
LifecycleScope.Session,
ISessionOutcomeRecorder,
SessionOutcomeRecorder,
ScopeActivation.OnScopeCreated,
'sessionActivity',
);
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
*
* Defines the `SessionMeta` model and the `ISessionMetadata` used by upper
* layers to read and update the session's durable metadata (title, timestamps,
* archived flag, fork provenance). Owns the in-memory copy, persists it as a
* archived flag, fork provenance, the latest main turn's terminal outcome).
* Owns the in-memory copy, persists it as a
* single atomic document through `storage`, and notifies changes via
* `onDidChangeMetadata`. Session-scoped — one instance per session. The initial
* document is materialized when the session is created.
Expand Down Expand Up @@ -36,6 +37,7 @@ export interface SessionMeta {
readonly forkedFrom?: string;
readonly agents?: Readonly<Record<string, AgentMeta>>;
readonly custom?: Record<string, unknown>;
readonly lastTurnOutcome?: 'completed' | 'cancelled' | 'failed';
}

export type SessionMetaPatch = Partial<Omit<SessionMeta, 'id' | 'createdAt'>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
updatedAt: this.data.updatedAt,
archived: this.data.archived === true,
custom: this.data.custom,
lastTurnReason: this.data.lastTurnOutcome,
}),
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';

import { Emitter } from '#/_base/event';
import type { DomainEvent } from '#/app/event/eventBus';
import { IEventBus } from '#/app/event/eventBus';
import type { IAgentScopeHandle } from '#/_base/di/scope';
import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import {
IAgentLifecycleService,
MAIN_AGENT_ID,
} from '#/session/agentLifecycle/agentLifecycle';
import { SessionOutcomeRecorder } from '#/session/sessionActivity/sessionOutcomeRecorderService';

class FakeBus {
private readonly handlers = new Map<string, Array<(e: DomainEvent) => void>>();

publish(event: DomainEvent): void {
for (const h of this.handlers.get(event.type) ?? []) h(event);
}

subscribe(type: unknown, handler?: unknown) {
const list = this.handlers.get(type as string) ?? [];
const fn = handler as (e: DomainEvent) => void;
list.push(fn);
this.handlers.set(type as string, list);
return { dispose: () => this.handlers.set(type as string, list.filter((h) => h !== fn)) };
}
}

describe('SessionOutcomeRecorder', () => {
function harness(opts?: { withMain?: boolean }) {
const withMain = opts?.withMain ?? true;
const bus = new FakeBus();
const mainHandle = {
id: MAIN_AGENT_ID,
accessor: { get: (token: unknown) => (token === IEventBus ? bus : undefined) },
} as unknown as IAgentScopeHandle;
const onDidCreate = new Emitter<IAgentScopeHandle>();
let mainPresent = withMain;
const agents = {
get: (id: string) => (id === MAIN_AGENT_ID && mainPresent ? mainHandle : undefined),
onDidCreate: onDidCreate.event,
} as unknown as IAgentLifecycleService;
const writes: SessionMeta['lastTurnOutcome'][] = [];
const metadata = {
update: async (patch: { lastTurnOutcome?: SessionMeta['lastTurnOutcome'] }) => {
writes.push(patch.lastTurnOutcome);
},
} as unknown as ISessionMetadata;
const recorder = new SessionOutcomeRecorder(agents, metadata);
Comment thread
wbxl2000 marked this conversation as resolved.
Outdated
return {
writes,
ended: (reason: string, interruptReason?: string) =>
bus.publish({ type: 'turn.ended', reason, interruptReason } as unknown as DomainEvent),
arriveMain: () => {
mainPresent = true;
onDidCreate.fire(mainHandle);
},
dispose: () => {
recorder.dispose();
},
};
}

it('persists completed and failed outcomes from the main agent', () => {
const { writes, ended, dispose } = harness();
ended('completed');
ended('failed');
ended('blocked'); // folds to 'failed', which the dedup then collapses
expect(writes).toEqual(['completed', 'failed']);
dispose();
});

it('persists a user stop but never a programmatic abort', () => {
const { writes, ended, dispose } = harness();
ended('cancelled', 'user_cancelled');
ended('cancelled', 'aborted');
ended('cancelled', undefined);
expect(writes).toEqual(['cancelled']);
dispose();
});

it('dedupes repeated outcomes within the process', () => {
const { writes, ended, dispose } = harness();
ended('failed');
ended('failed');
expect(writes).toEqual(['failed']);
dispose();
});

it('subscribes when the main agent appears after construction', () => {
const { writes, ended, arriveMain, dispose } = harness({ withMain: false });
ended('failed');
expect(writes).toEqual([]);
arriveMain();
ended('failed');
expect(writes).toEqual(['failed']);
dispose();
});
});
13 changes: 11 additions & 2 deletions packages/kap-server/src/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,7 @@ export interface SessionWireFields {
readonly updatedAt: number;
readonly archived: boolean;
readonly custom?: Record<string, unknown>;
readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}

export function toWireSession(
Expand All @@ -1125,7 +1126,11 @@ export function toWireSession(
busy: facts.busy,
main_turn_active: facts.mainTurnActive,
pending_interaction: facts.pendingInteraction,
last_turn_reason: facts.lastTurnReason,
// Live facts win for warm sessions; only a cold session (no live handle)
// falls back to the persisted outcome — a warm session mid-turn must not
// report a stale earlier outcome.
last_turn_reason:
facts.lastTurnReason ?? (facts.live === false ? fields.lastTurnReason : undefined),
Comment thread
wbxl2000 marked this conversation as resolved.
archived: fields.archived,
last_prompt: fields.lastPrompt,
metadata: buildWireMetadata(fields.custom, cwd),
Expand All @@ -1143,6 +1148,9 @@ export interface SessionFacts {
readonly mainTurnActive: boolean;
readonly pendingInteraction: SessionPendingInteraction;
readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed';
/** False when no live handle exists (cold session); live warm sessions
* always report their own outcome, never the persisted fallback. */
readonly live?: boolean;
}

/**
Expand All @@ -1159,9 +1167,10 @@ export function resolveSessionFacts(core: Scope, sessionId: string): SessionFact
busy: false,
mainTurnActive: false,
pendingInteraction: 'none',
live: false,
};
}
return handle.accessor.get(ISessionActivityView).state();
return { ...handle.accessor.get(ISessionActivityView).state(), live: true };
}

/**
Expand Down
Loading
Loading