Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/agent-lifecycle-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Add agent.created and agent.disposed events to the server session event stream, and expose each agent's disposal time in the transcript API.
10 changes: 10 additions & 0 deletions packages/kap-server/src/protocol/events-zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,14 @@ export const sessionMetaUpdatedEventSchema = z.object({
patch: z.record(z.string(), z.unknown()).optional(),
});

export const agentCreatedEventSchema = z.object({
type: z.literal('agent.created'),
});

export const agentDisposedEventSchema = z.object({
type: z.literal('agent.disposed'),
});
Comment on lines +543 to +549

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the shared protocol catalog in sync

Adding these events only to kap-server's private protocol catalog leaves @moonshot-ai/protocol stale; I verified rg "agent.created|agent.disposed" packages/protocol returns no matches, so packages/protocol/src/events.ts still omits these types from AgentEvent and sessionEventMessageSchema. Any SDK/client/test code validating or typing WS frames through the shared package will reject or fail to model events the server now emits.

Useful? React with 👍 / 👎.


export const sessionCreatedEventSchema = z.object({
type: z.literal('event.session.created'),
session: sessionSchema,
Expand Down Expand Up @@ -904,6 +912,8 @@ export const agentEventSchema = z.discriminatedUnion('type', [
errorEventSchema,
warningEventSchema,
agentStatusUpdatedEventSchema,
agentCreatedEventSchema,
agentDisposedEventSchema,
sessionMetaUpdatedEventSchema,
sessionCreatedEventSchema,
workspaceCreatedEventSchema,
Expand Down
1 change: 1 addition & 0 deletions packages/kap-server/src/services/transcript/coreBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export function bindSessionTranscript(
agentDisposables.delete(agentId);
subscribedAgents.delete(agentId);
projectors.delete(agentId);
store.markDisposed(agentId, new Date().toISOString());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist disposedAt before dropping the live store

When a session is closed or the daemon restarts, TranscriptService.dropSession discards this live TranscriptStore and the REST transcript route falls back to readColdRoster from state.json; this line only stamps the in-memory store and no metadata update is written. In that cold-session path, agents that were disposed during the live run lose disposedAt, so transcript API consumers viewing history cannot distinguish them from non-disposed roster entries.

Useful? React with 👍 / 👎.

}),
);

Expand Down
10 changes: 10 additions & 0 deletions packages/kap-server/src/transport/ws/v1/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ export interface AgentStatusUpdatedEvent {
readonly phase?: AgentPhase;
}

export interface AgentCreatedEvent {
readonly type: 'agent.created';
}

export interface AgentDisposedEvent {
readonly type: 'agent.disposed';
}

export interface SessionMetaUpdatedEvent {
readonly type: 'session.meta.updated';
readonly title?: string;
Expand Down Expand Up @@ -160,6 +168,8 @@ export interface BackgroundTaskTerminatedEvent {
export type AgentEvent =
| DomainEvent
| AgentStatusUpdatedEvent
| AgentCreatedEvent
| AgentDisposedEvent
| SessionMetaUpdatedEvent
| SessionCreatedEvent
| WorkspaceCreatedEvent
Expand Down
40 changes: 32 additions & 8 deletions packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
* 1. Subscribes to every agent's `IEventBus` via
* `IAgentLifecycleService` reach-down-via-handle (and `onDidCreate` /
* `onDidDispose` for late agents); `record` emissions are persisted and not
* broadcast (see step 3). Also subscribes to the session's
* broadcast (see step 3). The same lifecycle callbacks fan durable
* `agent.created` / `agent.disposed` facts out at session granularity
* (they bypass per-subscription agent allowlists but never leave the
* session). Also subscribes to the session's
* `ISessionInteractionService` and synthesizes the v1 approval/question
* protocol events from pending-set changes and resolutions.
* 2. Attaches `agentId`/`sessionId` to build the wire `Event`.
Expand Down Expand Up @@ -827,12 +830,27 @@ export class SessionEventBroadcaster {
};
for (const handle of agents.list()) subscribeAgent(handle);
state.lifecycleDisposables.push(
agents.onDidCreate((handle) => subscribeAgent(handle)),
agents.onDidCreate((handle) => {
subscribeAgent(handle);
// Session-grained lifecycle fact, ahead of any of the agent's own
// events: `onDidCreate` fires before the agent's eager services
// ignite, so this enqueue lands first in the queue.
this.enqueueDurable(state, {
type: 'agent.created',
agentId: handle.id,
sessionId,
});
}),
agents.onDidDispose((agentId) => {
const d = state.agentDisposables.get(agentId);
if (d !== undefined) {
d.dispose();
state.agentDisposables.delete(agentId);
this.enqueueDurable(state, {
type: 'agent.disposed',
agentId,
sessionId,
});
}
// A removed agent can no longer contribute work; drop its fold and
// re-evaluate the aggregate (its turn.ended normally lands first, but
Expand Down Expand Up @@ -1246,14 +1264,19 @@ function isGlobalEvent(type: string): boolean {
);
}

function isAgentLifecycleEvent(type: string): boolean {
return type === 'agent.created' || type === 'agent.disposed';
}

/**
* Per-subscription agent allowlist check — shared by live fan-out and replay.
* Returns `true` when the envelope should be delivered to a subscriber carrying
* `filter`:
* - `filter === undefined` → receive every agent (legacy session-grained
* behavior);
* - global events (session/workspace/config) are not agent
* events and always pass;
* - global events (session/workspace/config) and agent lifecycle events
* (`agent.created` / `agent.disposed`) are not per-agent stream content
* and always pass;
* - events without a string `agentId` (should not happen on the v1 wire,
* where the broadcaster stamps every event) pass defensively rather than
* being dropped;
Expand All @@ -1262,6 +1285,7 @@ function isGlobalEvent(type: string): boolean {
function matchesAgentFilter(envelope: EventEnvelope, filter: AgentFilter): boolean {
if (filter === undefined) return true;
if (isGlobalEvent(envelope.type)) return true;
if (isAgentLifecycleEvent(envelope.type)) return true;
const payload = envelope.payload;
const agentId =
typeof payload === 'object' && payload !== null
Expand Down Expand Up @@ -1367,8 +1391,8 @@ function sessionMetaUpdatedPayload(
const title = typeof candidate.title === 'string' ? candidate.title : undefined;
const patch =
typeof candidate.patch === 'object' &&
candidate.patch !== null &&
!Array.isArray(candidate.patch)
candidate.patch !== null &&
!Array.isArray(candidate.patch)
? candidate.patch
: undefined;
if (title === undefined && patch === undefined) return undefined;
Expand Down Expand Up @@ -1399,8 +1423,8 @@ function sessionCreatedPayload(
: undefined;
const session =
typeof candidate.session === 'object' &&
candidate.session !== null &&
!Array.isArray(candidate.session)
candidate.session !== null &&
!Array.isArray(candidate.session)
? (candidate.session as SessionCreatedEvent['session'])
: undefined;
if (sessionId === undefined || session === undefined) return undefined;
Expand Down
10 changes: 8 additions & 2 deletions packages/kap-server/test/services/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1120,16 +1120,22 @@ describe('bindSessionTranscript', () => {
);

const sub = agents.add('sub-1');
agents.add('main');
sub.bus.emit(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'scan' }));
sub.bus.emit(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' }));
expect(store.getAgent('sub-1')?.getItems()).toHaveLength(1);

// Disposal kills the projector but must not drop already-served history:
// the service's backfill cache dedupes per agent, so removing the
// transcript would rebuild an empty shell on the next read.
// transcript would rebuild an empty shell on the next read. The roster
// entry stays and carries its end timestamp so REST / fresh-reset
// consumers can tell the dead agent from a live one.
agents.remove('sub-1');
expect(store.getAgent('sub-1')?.getItems()).toHaveLength(1);
expect(store.agents().map((a) => a.agentId)).toContain('sub-1');
const descriptor = store.agents().find((a) => a.agentId === 'sub-1');
expect(descriptor).toBeDefined();
expect(typeof descriptor?.disposedAt).toBe('string');
expect(store.agents().find((a) => a.agentId === 'main')?.disposedAt).toBeUndefined();
binding.dispose();
});

Expand Down
69 changes: 66 additions & 3 deletions packages/kap-server/test/sessionEventBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,12 +441,75 @@ describe('SessionEventBroadcaster', () => {
await bc.subscribe('s1', target);

const late = lc.addAgent('main'); // created after subscribe
// Let the lifecycle dispatch drain before driving the turn — a
// synchronous emit would fold its activity ahead of the queued
// work_changed task and reorder it in front of agent.created (see the
// note above about the production interleaving).
await bc.getCursor('s1');
late.bus.emit(agentEvent('turn.started', { turnId: 7 }));
await bc.getCursor('s1');

// work_changed(busy) (seq 1) is emitted ahead of turn.started (seq 2);
// the volatile agent.status.updated phase frame rides alongside.
expect(envelopes.filter((e) => e.volatile !== true).map((e) => e.seq)).toEqual([1, 2]);
// agent.created (seq 1) leads; work_changed(busy) (seq 2) is emitted ahead
// of turn.started (seq 3); the volatile agent.status.updated phase frame
// rides alongside.
expect(envelopes.filter((e) => e.volatile !== true).map((e) => e.seq)).toEqual([1, 2, 3]);
expect(envelopes[0]).toMatchObject({ type: 'agent.created' });
expect((envelopes[0]!.payload as { agentId: string }).agentId).toBe('main');
});

it('broadcasts agent.disposed only for agents this state attached', async () => {
const lc = new FakeLifecycle();
lc.addAgent('main');
lc.addAgent('agent-0');
sessions.set('s1', lc);
const { target, envelopes } = collectingTarget();
await bc.subscribe('s1', target);

lc.removeAgent('agent-0');
// The creation-failure path fires onDidDispose for an agent that was
// never created (and never attached) — clients must not hear about it.
lc.removeAgent('ghost');
await bc.getCursor('s1');

const disposed = envelopes.filter((e) => e.type === 'agent.disposed');
expect(disposed).toHaveLength(1);
expect((disposed[0]!.payload as { agentId: string }).agentId).toBe('agent-0');
expect(disposed[0]!.volatile).toBeUndefined(); // durable
});

it('delivers lifecycle events past the agent allowlist (session-grained)', async () => {
const lc = new FakeLifecycle();
lc.addAgent('main');
sessions.set('s1', lc);
const { target, envelopes } = collectingTarget();
await bc.subscribe('s1', target, new Set(['main']));

lc.addAgent('agent-0'); // outside the allowlist
lc.removeAgent('agent-0');
await bc.getCursor('s1');

const types = envelopes.map((e) => e.type);
expect(types).toContain('agent.created');
expect(types).toContain('agent.disposed');
});

it('journals lifecycle events for replay', async () => {
const lc = new FakeLifecycle();
lc.addAgent('main');
sessions.set('s1', lc);
const { target } = collectingTarget();
await bc.subscribe('s1', target);

lc.addAgent('agent-0');
lc.removeAgent('agent-0');
await bc.getCursor('s1');

const result = await bc.getBufferedSince('s1', { seq: 0 });
expect(result.resyncRequired).toBe(false);
expect(result.events.map((e) => e.envelope.type)).toEqual([
'agent.created',
'agent.disposed',
]);
});

it('getSnapshotState returns the in-flight turn', async () => {
Expand Down
9 changes: 8 additions & 1 deletion packages/transcript/src/store/transcriptStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface AgentDescriptor {
readonly parentAgentId?: AgentId;
readonly label?: string;
readonly createdAt?: string;
readonly disposedAt?: string;
}

export type RosterListener = (agents: readonly AgentDescriptor[]) => void;
Expand All @@ -27,7 +28,7 @@ export class TranscriptStore {
readonly #descriptors = new Map<AgentId, AgentDescriptor>();
readonly #rosterListeners = new Set<RosterListener>();

constructor(readonly sessionId: string) {}
constructor(readonly sessionId: string) { }

/** Lazily create (or fetch) the transcript for an agent. */
ensureAgent(agentId: AgentId, descriptor?: AgentDescriptor): AgentTranscript {
Expand Down Expand Up @@ -62,6 +63,12 @@ export class TranscriptStore {
}
}

markDisposed(agentId: AgentId, disposedAt: string): void {
const descriptor = this.#descriptors.get(agentId);
if (descriptor === undefined || descriptor.disposedAt !== undefined) return;
this.describeAgent({ ...descriptor, disposedAt });
}

agents(): readonly AgentDescriptor[] {
return [...this.#descriptors.values()];
}
Expand Down
1 change: 1 addition & 0 deletions packages/transcript/src/wire/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ export const agentDescriptorSchema = z.object({
parentAgentId: agentIdSchema.optional(),
label: z.string().optional(),
createdAt: z.string().optional(),
disposedAt: z.string().optional(),
});

export const transcriptResponseSchema = z.object({
Expand Down
24 changes: 24 additions & 0 deletions packages/transcript/test/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,4 +439,28 @@ describe('TranscriptStore', () => {
expect(rosters).toEqual([2, 1]);
expect(store.agents().map((a) => a.agentId)).toEqual(['main']);
});

it('markDisposed stamps disposedAt on the existing descriptor only', () => {
const store = new TranscriptStore('s1');
store.ensureAgent('main', { agentId: 'main', type: 'main' });

// Never-announced agents must not gain a roster entry.
store.markDisposed('ghost', '2026-07-20T00:00:00.000Z');
expect(store.agents().map((a) => a.agentId)).toEqual(['main']);

const rosters: Array<readonly string[]> = [];
store.onRosterChange((agents) => rosters.push(agents.map((a) => a.agentId)));
store.markDisposed('main', '2026-07-20T01:00:00.000Z');
expect(rosters).toEqual([['main']]);
expect(store.agents()[0]).toMatchObject({
agentId: 'main',
type: 'main',
disposedAt: '2026-07-20T01:00:00.000Z',
});

// Idempotent: the first stamp wins and no roster re-emit fires.
store.markDisposed('main', '2026-07-20T02:00:00.000Z');
expect(store.agents()[0]?.disposedAt).toBe('2026-07-20T01:00:00.000Z');
expect(rosters).toHaveLength(1);
});
});
Loading