Skip to content
Open
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/fork-transcript-own-turns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix /btw side-chat transcripts showing inherited main-conversation turns with mismatched prompts and answers.
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 @@ -1241,6 +1241,7 @@ export interface AgentStateSnapshot {
detail?: unknown;
}>;
readonly note?: string;
readonly inherited?: boolean;
})[];
// src/agent/contextProjector/contextProjectorService.ts
'contextProjector.lastRepairSignature': string | null;
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/docs/wire-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ interface ContextAppendMessagePayload {
isError?: boolean;
toolCallDisplays?: Record<string, ToolInputDisplay>;
note?: string;
inherited?: boolean;
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface MutableMessage {
isError?: boolean;
note?: string;
origin?: ContextMessage['origin'];
inherited?: boolean;
}

interface MutableEntry {
Expand Down Expand Up @@ -175,6 +176,7 @@ function toMutableEntry(message: ContextMessage, time: number | undefined): Muta
...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}),
...(message.isError !== undefined ? { isError: message.isError } : {}),
...(message.origin !== undefined ? { origin: message.origin } : {}),
inherited: message.inherited,
},
time,
};
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/agent/contextMemory/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export type ContextMessage = Message & {
readonly isError?: boolean;
toolCallDisplays?: Record<string, ToolInputDisplay>;
readonly note?: string;
readonly inherited?: boolean;
};

export interface UserMessageRecord {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,11 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle

const sourceMessages = source.accessor.get(IAgentContextMemoryService)?.get();
if (sourceMessages !== undefined && sourceMessages.length > 0) {
child.accessor
.get(IAgentContextMemoryService)
?.append(...closeTrailingOpenToolExchange(sourceMessages));
const inherited = closeTrailingOpenToolExchange(sourceMessages).map((message) => ({
...message,
inherited: true,
}));
child.accessor.get(IAgentContextMemoryService)?.append(...inherited);
}
return childContext;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,29 @@ describe('AgentLifecycleService', () => {
});
});

it('fork marks the seeded context messages as inherited without touching the source', async () => {
const svc = ix.get(IAgentLifecycleService);
const source = await svc.create({ agentId: 'main' });
const sourceHandle = svc.handleOf('main')!;
const history: ContextMessage[] = [
{ role: 'user', content: [{ type: 'text', text: 'analyze this repo' }], toolCalls: [] },
{ role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
];
sourceHandle.accessor.get(IAgentContextMemoryService).append(...history);

const child = await svc.fork(agentContextOf(sourceHandle), { agentId: 'forked' });

const seeded = svc.handleOf(child.agentId)!.accessor.get(IAgentContextMemoryService).get();
expect(seeded).toHaveLength(2);
expect(seeded.every((message) => message.inherited === true)).toBe(true);
expect(
sourceHandle.accessor
.get(IAgentContextMemoryService)
.get()
.every((message) => message.inherited === undefined),
).toBe(true);
});

it('fork leaves the child context empty when the source history is empty', async () => {
const svc = ix.get(IAgentLifecycleService);
const source = await svc.create({ agentId: 'main' });
Expand Down
52 changes: 42 additions & 10 deletions packages/kap-server/src/services/transcript/transcriptService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,19 +425,26 @@ export class TranscriptService {
async readColdRoster(sessionId: string): Promise<AgentDescriptor[] | undefined> {
const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId);
if (summary === undefined) return undefined;
let meta: SessionMeta;
const meta = await this.readSessionMeta(summary.workspaceId, sessionId);
if (meta === undefined) return [];
return Object.entries(meta.agents ?? {}).map(([agentId, agentMeta]) =>
descriptorFromMeta(agentId, agentMeta),
);
}

private async readSessionMeta(
workspaceId: string,
sessionId: string,
): Promise<SessionMeta | undefined> {
try {
const raw = await readFile(
join(this.deps.homeDir, SESSIONS_ROOT, summary.workspaceId, sessionId, STATE_FILE),
join(this.deps.homeDir, SESSIONS_ROOT, workspaceId, sessionId, STATE_FILE),
'utf-8',
);
meta = JSON.parse(raw) as SessionMeta;
return JSON.parse(raw) as SessionMeta;
} catch {
return [];
return undefined;
}
return Object.entries(meta.agents ?? {}).map(([agentId, agentMeta]) =>
descriptorFromMeta(agentId, agentMeta),
);
}

async readColdSnapshot(
Expand Down Expand Up @@ -467,13 +474,20 @@ export class TranscriptService {
}
throw error;
}
const messages = [...reduceContextTranscript(records).entries];
const meta = await this.readSessionMeta(summary.workspaceId, sessionId);
const forkedFrom = meta?.agents?.[agentId]?.forkedFrom;
const projectionRecords = stripLegacyInheritedRecords(records, forkedFrom);
const messages = [...reduceContextTranscript(projectionRecords).entries].filter(
(message) =>
message.inherited !== true &&
(forkedFrom === undefined || message.origin?.kind !== 'compaction_summary'),
);
Comment thread
liukx0205 marked this conversation as resolved.
const taskOriginTurnTaskIds = new Set<string>();
const steeredContents = new Map<string, Map<string, number>>();
const anchorStack: { taskIdsSnapshot: Set<string> }[] = [];
let anchorFloor = 0;
let sawTurnPrompt = false;
for (const record of records) {
for (const record of projectionRecords) {
if (record.type === 'context.undo') {
const count = typeof record['count'] === 'number' ? (record['count'] as number) : 0;
for (let i = 0; i < count && anchorStack.length > anchorFloor; i++) {
Expand Down Expand Up @@ -521,7 +535,7 @@ export class TranscriptService {
messages,
sawTurnPrompt || steeredContents.size > 0 ? { taskOriginTurnTaskIds, steeredContents } : undefined,
);
const folded = foldWireRecordFacts(projectQuestionInteractionRecords(records, sessionId), base, {
const folded = foldWireRecordFacts(projectQuestionInteractionRecords(projectionRecords, sessionId), base, {
resolvePlanRevisionKey: (key) =>
join(SESSIONS_ROOT, summary.workspaceId, sessionId, AGENTS_DIR, agentId, key),
});
Expand Down Expand Up @@ -629,6 +643,24 @@ const TERMINAL_TURN_STATES: ReadonlySet<TranscriptTurn['state']> = new Set([
'cancelled',
]);

function stripLegacyInheritedRecords(
records: readonly ContextRecord[],
forkedFrom: string | undefined,
): readonly ContextRecord[] {
if (forkedFrom === undefined) return records;
const marked = records.some(
(record) =>
record.type === 'context.append_message' &&
(record as { message?: ContextMessage }).message?.inherited === true,
);
if (marked) return records;
const firstPrompt = records.findIndex((record) => record.type === 'turn.prompt');
const cutoff = firstPrompt === -1 ? records.length : firstPrompt;
return records.filter(
(record, index) => index >= cutoff || record.type !== 'context.append_message',
);
}

function projectQuestionInteractionRecords(
records: readonly ContextRecord[],
sessionId: string,
Expand Down
Loading
Loading