wip: checkpoint transcript-derived spine tree and web/TUI projection

This commit is contained in:
7Sageer 2026-07-17 12:08:40 +08:00
parent 82b009bc54
commit 1cbd845bb9
43 changed files with 2008 additions and 1170 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Show the parent goal in the experimental Spine status line, and refine the task-tree prompt so status reports are not treated as task boundaries.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Derive the experimental Spine task tree directly from the conversation transcript rather than a separate record, so undo, clear, and session resume keep the tree consistent with what the model saw.

View file

@ -3,9 +3,10 @@
*
* Pure reducer that mirrors the main agent's Spine task tree from the
* transcript alone no server/protocol changes: the TUI already sees every
* top-level tool call and its result, and the Spine control tools answer
* with a literal `accepted` receipt, so the panel can be driven entirely
* app-side, through the same channel the legacy `TodoList` scrape uses.
* top-level tool call and its result, and core's contract is accepted
* non-error tool result (a rejected transition surfaces its reason as an
* error), so the panel can be driven entirely app-side, through the same
* channel the legacy `TodoList` scrape uses.
*
* The tree is rebuilt from accepted transitions in transcript order:
* spine_open(summary) push a child under the cursor; cursor = child
@ -14,9 +15,10 @@
* The panel renders the mirrored tree directly (closed done, the open
* cursor chain in_progress, cursor flagged `active`), folding done
* subtrees panel-side; see `projectSpineTree`. Rejected transitions (error
* results, or stored results whose text is not the `accepted` receipt) never
* touch the state; that also bounds drift to a single transition when core
* drops a pending move (e.g. abort before the afterStep commit).
* results live; stored results whose text lacks the `accepted` receipt
* prefix on replay) never touch the state; that also bounds drift to a
* single transition when core drops a pending move (e.g. abort before the
* afterStep commit).
*
* Subagents route their sub-tool calls through `subagent.*` events rather
* than the top-level tool result stream, so the projection only ever tracks
@ -33,7 +35,10 @@ const SPINE_CONTROL_TOOL_NAMES: ReadonlySet<string> = new Set([
'spine_next',
]);
/** Literal output the spine control tools return when core accepts the intent. */
/** Prefix of the receipt the spine control tools return when core accepts the
* intent core's ACCEPTED_OUTPUT is `accepted — commits after this step
* completes`; the replay scan matches the prefix so suffix wording tweaks
* don't break resume. */
export const SPINE_ACCEPTED_RECEIPT = 'accepted';
export function isSpineControlToolName(name: string): name is SpineControlToolName {
@ -69,8 +74,8 @@ function readSummary(args: Record<string, unknown>): string | null {
/**
* Applies one accepted transition. Callers must only pass results core
* accepted (`event.isError === false` live; the `accepted` receipt on replay)
* rejected or malformed calls leave the state untouched.
* accepted (`event.isError === false` live; an `accepted`-prefixed receipt
* on replay) rejected or malformed calls leave the state untouched.
*/
export function applyAcceptedSpineTransition(
state: SpineProjectionState,
@ -216,7 +221,7 @@ export function scanSpineProjectionFromHistory(
const call = pending.get(message.toolCallId);
if (call === undefined) continue;
pending.delete(message.toolCallId);
if (textContent(message.content).trim() !== SPINE_ACCEPTED_RECEIPT) continue;
if (!textContent(message.content).trim().startsWith(SPINE_ACCEPTED_RECEIPT)) continue;
state = applyAcceptedSpineTransition(state, call.name, call.args);
}
return state;

View file

@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
SPINE_ACCEPTED_RECEIPT,
applyAcceptedSpineTransition,
createSpineProjectionState,
isSpineProjectionActive,
@ -9,6 +8,10 @@ import {
scanSpineProjectionFromHistory,
} from '#/tui/utils/spine-projection';
/** Core's real accepted receipt (ACCEPTED_OUTPUT in agent-core-v2's
* controlResult.ts) the replay scan must match it. */
const ACCEPTED_OUTPUT = 'accepted — commits after this step completes';
function assistant(callId: string, name: string, args: Record<string, unknown>) {
return {
role: 'assistant',
@ -151,11 +154,11 @@ describe('spine projection history scan', () => {
it('rebuilds the tree from accepted receipts in transcript order', () => {
const history = [
assistant('c1', 'spine_open', { summary: 'task A' }),
tool('c1', SPINE_ACCEPTED_RECEIPT),
tool('c1', ACCEPTED_OUTPUT),
bash('c2'),
tool('c2', 'hi\n'),
assistant('c3', 'spine_next', { summary: 'task B', memory: 'A done' }),
tool('c3', SPINE_ACCEPTED_RECEIPT),
tool('c3', ACCEPTED_OUTPUT),
];
const state = scanSpineProjectionFromHistory(history);
@ -168,11 +171,11 @@ describe('spine projection history scan', () => {
it('skips rejected transitions so the tree stays truthful', () => {
const history = [
assistant('c1', 'spine_open', { summary: 'task A' }),
tool('c1', SPINE_ACCEPTED_RECEIPT),
tool('c1', ACCEPTED_OUTPUT),
assistant('c2', 'spine_close', { memory: 'root close gets rejected' }),
tool('c2', 'Root-epoch nodes cannot be closed. Use open to start a child node under the current scope.'),
assistant('c3', 'spine_next', { summary: 'task B', memory: 'moving on' }),
tool('c3', SPINE_ACCEPTED_RECEIPT),
tool('c3', ACCEPTED_OUTPUT),
];
// The rejected close did not pop the cursor, so `task B` landed as a
@ -187,11 +190,11 @@ describe('spine projection history scan', () => {
it('rebuilds nested structure from the stored history', () => {
const history = [
assistant('c1', 'spine_open', { summary: 'parent' }),
tool('c1', SPINE_ACCEPTED_RECEIPT),
tool('c1', ACCEPTED_OUTPUT),
assistant('c2', 'spine_open', { summary: 'child' }),
tool('c2', SPINE_ACCEPTED_RECEIPT),
tool('c2', ACCEPTED_OUTPUT),
assistant('c3', 'spine_close', { memory: 'child done' }),
tool('c3', SPINE_ACCEPTED_RECEIPT),
tool('c3', ACCEPTED_OUTPUT),
];
const state = scanSpineProjectionFromHistory(history);
@ -208,9 +211,9 @@ describe('spine projection history scan', () => {
it('applies a tool result only to the first matching call', () => {
const history = [
assistant('c1', 'spine_open', { summary: 'task A' }),
tool('c1', SPINE_ACCEPTED_RECEIPT),
tool('c1', SPINE_ACCEPTED_RECEIPT),
tool('c9', SPINE_ACCEPTED_RECEIPT),
tool('c1', ACCEPTED_OUTPUT),
tool('c1', ACCEPTED_OUTPUT),
tool('c9', ACCEPTED_OUTPUT),
];
const state = scanSpineProjectionFromHistory(history);
@ -249,7 +252,7 @@ describe('spine projection history scan', () => {
const history = transitions.flatMap(([name, args], index) => {
const callId = `c${String(index)}`;
live = applyAcceptedSpineTransition(live, name, args);
return [assistant(callId, name, args), tool(callId, SPINE_ACCEPTED_RECEIPT)];
return [assistant(callId, name, args), tool(callId, ACCEPTED_OUTPUT)];
});
expect(scanSpineProjectionFromHistory(history)).toEqual(live);

View file

@ -717,6 +717,7 @@ function openPr(url: string): void {
:git-info="client.gitInfo.value"
:tasks="client.tasks.value"
:todos="client.todos.value"
:todo-tree="client.todoTree.value"
:goal="client.goal.value"
:activation-badges="client.activationBadges.value"
:status="client.status.value"

View file

@ -116,6 +116,7 @@ interface SessionState {
totalCacheRead: number;
totalCacheCreate: number;
contextTokens: number;
rawContextTokens: number;
contextLimit: number;
turnCount: number;
model: string;
@ -141,6 +142,7 @@ function createSessionState(): SessionState {
totalCacheRead: 0,
totalCacheCreate: 0,
contextTokens: 0,
rawContextTokens: 0,
contextLimit: 0,
turnCount: 0,
model: '',
@ -471,6 +473,7 @@ function buildUsageSnapshot(state: SessionState): AppSessionUsage {
cacheCreationTokens: state.totalCacheCreate,
totalCostUsd: 0,
contextTokens: state.contextTokens,
rawContextTokens: state.rawContextTokens,
contextLimit: state.contextLimit,
turnCount: state.turnCount,
};
@ -929,6 +932,7 @@ export function createAgentProjector(): AgentProjector {
case 'agent.status.updated': {
if (p?.model) s.model = p.model;
if (p?.contextTokens !== undefined) s.contextTokens = p.contextTokens;
if (p?.rawContextTokens !== undefined) s.rawContextTokens = p.rawContextTokens;
if (p?.maxContextTokens !== undefined) s.contextLimit = p.maxContextTokens;
out.push({

View file

@ -459,6 +459,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
planMode: data.plan_mode === true,
swarmMode: data.swarm_mode === true,
contextTokens: data.context_tokens ?? 0,
rawContextTokens: data.raw_context_tokens ?? 0,
maxContextTokens: data.max_context_tokens ?? 0,
contextUsage: data.context_usage ?? 0,
};

View file

@ -106,6 +106,7 @@ export interface WireSessionRuntimeStatus {
plan_mode: boolean;
swarm_mode: boolean;
context_tokens: number;
raw_context_tokens?: number;
max_context_tokens: number;
context_usage: number;
}

View file

@ -55,6 +55,9 @@ export interface AppSessionUsage {
cacheCreationTokens: number;
totalCostUsd: number;
contextTokens: number;
/** Unfolded-request cost (>= contextTokens once a fold reclaimed context).
* Absent until the daemon reports it (old daemons never do). */
rawContextTokens?: number;
contextLimit: number;
turnCount: number;
}
@ -99,6 +102,8 @@ export interface AppSessionRuntimeStatus {
planMode: boolean;
swarmMode: boolean;
contextTokens: number;
/** Unfolded-request cost; 0 when the daemon predates the field. */
rawContextTokens: number;
maxContextTokens: number;
contextUsage: number;
}

View file

@ -3,9 +3,9 @@
<!-- pending question/approval cards, and the composer. Only rendered inside a -->
<!-- chat-pane group so it never leaks into files/tasks/preview/btw panes. -->
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ActivationBadges, ApprovalBlock, ConversationStatus, PermissionMode, QueuedPromptView, TaskItem, TodoView, UIQuestion } from '../../types';
import type { ActivationBadges, ApprovalBlock, ConversationStatus, PermissionMode, QueuedPromptView, TaskItem, TodoTreeNode, TodoView, UIQuestion } from '../../types';
import type { AppGoal, AppModel, AppSkill, QuestionResponse, ThinkingLevel } from '../../api/types';
import type { FileItem } from './MentionMenu.vue';
import Composer from './Composer.vue';
@ -46,6 +46,9 @@ const props = defineProps<{
todoDoneCount: number;
hasDockWork: boolean;
todos?: TodoView[];
/** Spine task tree mirrored from the transcript when non-empty it
replaces the flat todo list in the 'todos' dock panel. */
todoTree?: TodoTreeNode[];
pendingQuestion?: UIQuestion;
/** Action kind in flight for the visible question (drives loading state). */
questionBusyKind?: 'answer' | 'dismiss';
@ -84,6 +87,24 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
/** True when the session drives its task list via spine the tree then
replaces the flat todo list in the dock. */
const spineActive = computed(() => (props.todoTree?.length ?? 0) > 0);
const treeStats = computed(() => {
let done = 0;
let total = 0;
const visit = (nodes: TodoTreeNode[]): void => {
for (const node of nodes) {
total += 1;
if (node.status === 'done') done += 1;
visit(node.children);
}
};
visit(props.todoTree ?? []);
return { done, total };
});
const composerRef = ref<{
loadForEdit: (value: string) => boolean;
loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]) => void;
@ -163,7 +184,8 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
v-else-if="dockPanel === 'todos'"
class="dock-work-tab static"
>
{{ t('tasks.dockTodos') }} · {{ todoDoneCount }}/{{ todos?.length ?? 0 }}
<template v-if="spineActive">{{ t('tasks.dockSpine') }} · {{ treeStats.done }}/{{ treeStats.total }}</template>
<template v-else>{{ t('tasks.dockTodos') }} · {{ todoDoneCount }}/{{ todos?.length ?? 0 }}</template>
</span>
</div>
<div class="dock-work-body">
@ -181,6 +203,7 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
<TodoCard
v-else-if="dockPanel === 'todos'"
:todos="todos ?? []"
:tree="todoTree"
/>
</div>
</div>
@ -214,14 +237,14 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
<span class="dw-count">(<b>{{ subagentTasks.length }}</b>)</span>
</Pill>
<Pill
v-if="(todos?.length ?? 0) > 0"
v-if="(todos?.length ?? 0) > 0 || spineActive"
:active="dockPanel === 'todos'"
:aria-pressed="dockPanel === 'todos'"
@click="emit('toggle-dock-panel', 'todos')"
>
<Icon name="check-list" size="md" />
<span>{{ t('tasks.dockTodos') }}</span>
<span class="dw-count">(<b>{{ todoDoneCount }}/{{ todos?.length ?? 0 }}</b>)</span>
<span>{{ spineActive ? t('tasks.dockSpine') : t('tasks.dockTodos') }}</span>
<span class="dw-count">(<b>{{ spineActive ? `${treeStats.done}/${treeStats.total}` : `${todoDoneCount}/${todos?.length ?? 0}` }}</b>)</span>
</Pill>
</div>

View file

@ -597,9 +597,22 @@ const pct = computed(() => {
return Math.min(100, Math.max(0, Math.round(((props.status?.ctxUsed ?? 0) / max) * 100)));
});
const rawPct = computed(() => {
const max = props.status?.ctxMax ?? 0;
if (max <= 0) return 0;
return Math.min(100, Math.max(0, Math.round(((props.status?.ctxRaw ?? 0) / max) * 100)));
});
// The raw (unfolded) reading only diverges from the projected one once a fold
// actually reclaimed context before that the ring and count stay single-value.
const hasRaw = computed(() => (props.status?.ctxRaw ?? 0) > (props.status?.ctxUsed ?? 0));
const ctxTooltip = computed(() => {
const used = (props.status?.ctxUsed ?? 0).toLocaleString();
const max = (props.status?.ctxMax ?? 0).toLocaleString();
if (hasRaw.value) {
const raw = (props.status?.ctxRaw ?? 0).toLocaleString();
return t('status.ctxTooltipRaw', { used, raw, max, pct: pct.value });
}
return t('status.ctxTooltip', { used, max, pct: pct.value });
});
@ -1092,8 +1105,8 @@ function selectModel(modelId: string): void {
tabindex="0"
:aria-label="ctxTooltip"
>
<ContextRing :pct="pct" />
<span class="ctx-num">{{ kFmt(status.ctxUsed) }}/{{ kFmt(status.ctxMax) }}</span>
<ContextRing :pct="pct" :raw-pct="hasRaw ? rawPct : undefined" />
<span class="ctx-num">{{ kFmt(status.ctxUsed) }}/<template v-if="hasRaw">{{ kFmt(status.ctxRaw) }}/</template>{{ kFmt(status.ctxMax) }}</span>
</span>
</Tooltip>

View file

@ -2,7 +2,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch, type ComponentPublicInstance } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ActivationBadges, ApprovalBlock, ChatTurn, ConversationStatus, FilePreviewRequest, PermissionMode, QueuedPromptView, TaskItem, TodoView, ToolMedia, UIQuestion, WorkspaceView } from '../../types';
import type { ActivationBadges, ApprovalBlock, ChatTurn, ConversationStatus, FilePreviewRequest, PermissionMode, QueuedPromptView, TaskItem, TodoTreeNode, TodoView, ToolMedia, UIQuestion, WorkspaceView } from '../../types';
import type { AppGoal, AppModel, AppSkill, QuestionResponse, ThinkingLevel } from '../../api/types';
import type { FileItem } from './MentionMenu.vue';
import ChatPane from './ChatPane.vue';
@ -26,6 +26,9 @@ const props = defineProps<{
tasks: TaskItem[];
/** Model-maintained todo list (TodoList tool) — shown as a floating card. */
todos?: TodoView[];
/** Spine task tree mirrored from the transcript when non-empty it
replaces the flat todo list in the dock (spine sessions never emit TodoList). */
todoTree?: TodoTreeNode[];
goal?: AppGoal | null;
activationBadges?: ActivationBadges;
status: ConversationStatus;
@ -247,6 +250,7 @@ const hasDockWork = computed(() =>
bashTasks.value.length > 0 ||
subagentTasks.value.length > 0 ||
(props.todos?.length ?? 0) > 0 ||
(props.todoTree?.length ?? 0) > 0 ||
(props.queued?.length ?? 0) > 0,
);
const dockPanel = ref<'bash' | 'subagent' | 'todos' | null>(null);
@ -1450,6 +1454,7 @@ defineExpose({ loadComposerForEdit, focusComposer });
:todo-done-count="todoDoneCount"
:has-dock-work="hasDockWork"
:todos="todos"
:todo-tree="todoTree"
:pending-question="pendingQuestion"
:question-busy-kind="questionBusyKind"
:pending-approval="pendingApproval"

View file

@ -5,16 +5,39 @@
Rows share StatusGlyph with the background bash/subagent task list so the
two stay visually identical. -->
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { TodoView } from '../../types';
import type { TodoTreeNode, TodoView } from '../../types';
import StatusGlyph, { type StatusGlyphStatus } from './StatusGlyph.vue';
const props = defineProps<{
todos: TodoView[];
/** Spine task tree mirrored from the transcript when non-empty it
replaces the flat todo list (spine sessions never emit TodoList). */
tree?: TodoTreeNode[];
}>();
const { t } = useI18n();
interface TreeRow {
node: TodoTreeNode;
depth: number;
}
/** Depth-first flattening of the spine tree: every node renders (done
subtrees included) the dock panel scrolls, so no folding like the TUI. */
const treeRows = computed<TreeRow[]>(() => {
const rows: TreeRow[] = [];
const visit = (nodes: TodoTreeNode[], depth: number): void => {
for (const node of nodes) {
rows.push({ node, depth });
visit(node.children, depth + 1);
}
};
visit(props.tree ?? [], 0);
return rows;
});
function glyphStatus(status: TodoView['status']): StatusGlyphStatus {
return status === 'in_progress' ? 'run' : status;
}
@ -22,7 +45,7 @@ function glyphStatus(status: TodoView['status']): StatusGlyphStatus {
<template>
<div class="todo-card">
<div v-if="props.todos.length === 0" class="tc-empty">
<div v-if="treeRows.length === 0 && props.todos.length === 0" class="tc-empty">
<svg class="tc-empty-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 11l2 2 4-4" />
<rect x="4" y="4" width="16" height="16" rx="3" />
@ -30,10 +53,29 @@ function glyphStatus(status: TodoView['status']): StatusGlyphStatus {
<span>{{ t('tasks.emptyTodo') }}</span>
</div>
<div v-for="(td, i) in props.todos" :key="i" class="tc-row" :class="`s-${td.status}`">
<StatusGlyph :status="glyphStatus(td.status)" />
<span class="tc-name">{{ td.title }}</span>
</div>
<template v-if="treeRows.length > 0">
<div
v-for="(row, i) in treeRows"
:key="i"
class="tc-row"
:class="[`s-${row.node.status}`, { 's-active': row.node.active }]"
>
<span
v-if="row.depth > 0"
class="tc-indent"
:style="{ width: `${row.depth * 14}px` }"
aria-hidden="true"
></span>
<StatusGlyph :status="glyphStatus(row.node.status)" />
<span class="tc-name">{{ row.node.title }}</span>
</div>
</template>
<template v-else>
<div v-for="(td, i) in props.todos" :key="i" class="tc-row" :class="`s-${td.status}`">
<StatusGlyph :status="glyphStatus(td.status)" />
<span class="tc-name">{{ td.title }}</span>
</div>
</template>
</div>
</template>
@ -58,6 +100,8 @@ function glyphStatus(status: TodoView['status']): StatusGlyphStatus {
color: var(--color-text-faint);
text-decoration: line-through;
}
.tc-row.s-active .tc-name { color: var(--color-accent); }
.tc-indent { flex: none; }
.tc-empty {
display: flex;

View file

@ -3,7 +3,10 @@
visualization (not a line icon), so it lives here rather than in the icon
registry. The arc length is derived from `pct`. -->
<script setup lang="ts">
const props = defineProps<{ pct: number }>();
// `rawPct` is the unfolded-request share of the window; it only diverges from
// `pct` once a fold reclaimed context, so the second arc stays hidden before
// that (raw >= projected by construction, the projected arc paints over it).
const props = defineProps<{ pct: number; rawPct?: number }>();
const R = 7;
const circumference = 2 * Math.PI * R;
@ -12,6 +15,18 @@ const circumference = 2 * Math.PI * R;
<template>
<svg class="ctx-ring" viewBox="0 0 20 20" aria-hidden="true">
<circle class="ctx-ring-track" cx="10" cy="10" :r="R" fill="none" stroke-width="2.5" />
<circle
v-if="props.rawPct !== undefined"
class="ctx-ring-raw"
cx="10"
cy="10"
:r="R"
fill="none"
stroke-width="2.5"
stroke-linecap="round"
:stroke-dasharray="`${circumference}`"
:stroke-dashoffset="`${circumference * (1 - props.rawPct / 100)}`"
/>
<circle
class="ctx-ring-fill"
cx="10"
@ -36,6 +51,10 @@ const circumference = 2 * Math.PI * R;
.ctx-ring-track {
stroke: var(--line);
}
.ctx-ring-raw {
stroke: var(--color-warning);
transition: stroke-dashoffset 0.3s ease;
}
.ctx-ring-fill {
stroke: var(--color-accent);
transition: stroke-dashoffset 0.3s ease, stroke 0.3s ease;

View file

@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest';
import type { AppMessage, AppMessageContent, AppMessageRole } from '../api/types';
import { spineTreeFromMessages } from './spineTree';
let seq = 0;
function msg(role: AppMessageRole, content: AppMessageContent[]): AppMessage {
seq += 1;
return { id: `m${seq}`, sessionId: 's1', role, content, createdAt: '2026-01-01T00:00:00Z' };
}
function call(id: string, toolName: string, input: unknown): AppMessage {
return msg('assistant', [{ type: 'toolUse', toolCallId: id, toolName, input }]);
}
function result(id: string, output: unknown, isError?: boolean): AppMessage {
return msg('tool', [{ type: 'toolResult', toolCallId: id, output, isError }]);
}
/** Core's real accepted receipt (ACCEPTED_OUTPUT in agent-core-v2's
* controlResult.ts). */
const ACCEPTED_RECEIPT = 'accepted — commits after this step completes';
const accepted = (id: string): AppMessage => result(id, ACCEPTED_RECEIPT);
describe('spineTreeFromMessages', () => {
it('returns [] for a transcript without spine calls', () => {
expect(spineTreeFromMessages([])).toEqual([]);
expect(
spineTreeFromMessages([
call('t1', 'TodoList', { todos: [{ title: 'x', status: 'pending' }] }),
result('t1', 'ok'),
]),
).toEqual([]);
});
it('opens a root node flagged active', () => {
const tree = spineTreeFromMessages([call('t1', 'spine_open', { summary: 'task A' }), accepted('t1')]);
expect(tree).toEqual([{ title: 'task A', status: 'in_progress', active: true, children: [] }]);
});
it('parses string tool input', () => {
const tree = spineTreeFromMessages([
call('t1', 'spine_open', JSON.stringify({ summary: 'task A' })),
accepted('t1'),
]);
expect(tree).toHaveLength(1);
expect(tree[0]!.title).toBe('task A');
});
it('nests children under the cursor and closes via spine_close', () => {
const tree = spineTreeFromMessages([
call('t1', 'spine_open', { summary: 'A' }),
accepted('t1'),
call('t2', 'spine_open', { summary: 'B' }),
accepted('t2'),
call('t3', 'spine_close', { memory: 'done B' }),
accepted('t3'),
]);
expect(tree).toEqual([
{
title: 'A',
status: 'in_progress',
active: true,
children: [{ title: 'B', status: 'done', children: [] }],
},
]);
});
it('spine_next closes the cursor and opens a sibling', () => {
const tree = spineTreeFromMessages([
call('t1', 'spine_open', { summary: 'A' }),
accepted('t1'),
call('t2', 'spine_open', { summary: 'B' }),
accepted('t2'),
call('t3', 'spine_next', { summary: 'C', memory: 'm' }),
accepted('t3'),
]);
expect(tree).toEqual([
{
title: 'A',
status: 'in_progress',
children: [
{ title: 'B', status: 'done', children: [] },
{ title: 'C', status: 'in_progress', active: true, children: [] },
],
},
]);
});
it('keeps the all-done tree after the last close (root epoch)', () => {
const tree = spineTreeFromMessages([
call('t1', 'spine_open', { summary: 'A' }),
accepted('t1'),
call('t2', 'spine_close', { memory: 'm' }),
accepted('t2'),
]);
expect(tree).toEqual([{ title: 'A', status: 'done', children: [] }]);
});
it('keeps closed history across epochs', () => {
const tree = spineTreeFromMessages([
call('t1', 'spine_open', { summary: 'A' }),
accepted('t1'),
call('t2', 'spine_close', { memory: 'm' }),
accepted('t2'),
call('t3', 'spine_open', { summary: 'B' }),
accepted('t3'),
]);
expect(tree).toEqual([
{ title: 'A', status: 'done', children: [] },
{ title: 'B', status: 'in_progress', active: true, children: [] },
]);
});
it('ignores rejected transitions (error results)', () => {
expect(
spineTreeFromMessages([
call('t1', 'spine_open', { summary: 'A' }),
result('t1', 'spine_open failed: summary required', true),
]),
).toEqual([]);
expect(
spineTreeFromMessages([
call('t1', 'spine_open', { summary: 'A' }),
result('t1', 'rejected: another transition pending', true),
]),
).toEqual([]);
});
it('applies non-error results regardless of output shape', () => {
const tree = spineTreeFromMessages([
call('t1', 'spine_open', { summary: 'A' }),
result('t1', [{ type: 'text', text: ACCEPTED_RECEIPT }]),
]);
expect(tree).toHaveLength(1);
});
it('ignores malformed calls: missing summary, next/close without a cursor', () => {
expect(
spineTreeFromMessages([
call('t1', 'spine_next', { summary: 'A', memory: 'm' }),
accepted('t1'),
call('t2', 'spine_close', { memory: 'm' }),
accepted('t2'),
call('t3', 'spine_open', {}),
accepted('t3'),
]),
).toEqual([]);
});
it('ignores results for unknown or already-settled call ids', () => {
const tree = spineTreeFromMessages([
result('nope', ACCEPTED_RECEIPT),
call('t1', 'spine_open', { summary: 'A' }),
accepted('t1'),
accepted('t1'), // duplicate — the pending entry was already consumed
call('t2', 'Read', { path: 'x' }),
result('t2', ACCEPTED_RECEIPT),
]);
expect(tree).toEqual([{ title: 'A', status: 'in_progress', active: true, children: [] }]);
});
});

View file

@ -0,0 +1,154 @@
// apps/kimi-web/src/composables/spineTree.ts
// Derives the CURRENT spine task tree from a session transcript. The agent
// drives its Spine task tree via the spine_open / spine_close / spine_next
// control tools, and core's contract is accepted ⟺ non-error tool result
// (a rejected transition surfaces its reason as an error), so the tree can
// be rebuilt app-side from the transcript alone — the same scrape channel
// the TodoList projection (latestTodos) uses, and the same isError-only
// check the TUI's live path applies.
// Replaying the accepted transitions in transcript order mirrors the tree:
// spine_open(summary) → push a child under the cursor; cursor = child
// spine_close(memory) → close the cursor node; pop
// spine_next(summary, memory) → close the cursor node; open a sibling
// Closed nodes render as done, the open cursor chain as in_progress, and the
// cursor node is flagged `active`. The tree persists after the last close:
// back at the root epoch it stays visible as all-done history, so only a
// transcript without any spine activity yields [] (the dock then falls back
// to the flat todo list). Rejected transitions (error results) never touch
// the tree. Differs from the TUI's spine-projection.ts, which returns [] at
// the root epoch.
import type { AppMessage } from '../api/types';
import type { TodoTreeNode } from '../types';
import { normalizeToolName } from '../lib/toolMeta';
type SpineControlToolName = 'spine_open' | 'spine_close' | 'spine_next';
const SPINE_CONTROL_TOOL_NAMES: ReadonlySet<string> = new Set([
'spine_open',
'spine_close',
'spine_next',
]);
function isSpineControlToolName(name: string): name is SpineControlToolName {
return SPINE_CONTROL_TOOL_NAMES.has(name);
}
interface SpineNode {
summary: string;
parentIndex: number | null;
closed: boolean;
}
function parseArgs(input: unknown): Record<string, unknown> {
let value = input;
if (typeof value === 'string') {
if (value.trim().length === 0) return {};
try {
value = JSON.parse(value);
} catch {
return {};
}
}
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readSummary(args: Record<string, unknown>): string | null {
const summary = args['summary'];
return typeof summary === 'string' && summary.trim().length > 0 ? summary : null;
}
function applyTransition(
nodes: SpineNode[],
cursorStack: number[],
name: SpineControlToolName,
args: Record<string, unknown>,
): void {
switch (name) {
case 'spine_open': {
const summary = readSummary(args);
if (summary === null) return;
const parentIndex = cursorStack.at(-1) ?? null;
nodes.push({ summary, parentIndex, closed: false });
cursorStack.push(nodes.length - 1);
return;
}
case 'spine_close': {
const cursor = cursorStack.at(-1);
if (cursor === undefined) return;
nodes[cursor]!.closed = true;
cursorStack.pop();
return;
}
case 'spine_next': {
const summary = readSummary(args);
const cursor = cursorStack.at(-1);
if (summary === null || cursor === undefined) return;
const parentIndex = nodes[cursor]?.parentIndex ?? null;
nodes[cursor]!.closed = true;
nodes.push({ summary, parentIndex, closed: false });
cursorStack[cursorStack.length - 1] = nodes.length - 1;
return;
}
}
}
function projectTree(nodes: SpineNode[], cursorStack: number[]): TodoTreeNode[] {
// No early return at the root epoch: a fully closed tree stays visible as
// all-done history. A transcript without spine activity still yields [] via
// the empty children map.
const cursor = cursorStack.at(-1);
const childrenByParent = new Map<number | null, number[]>();
for (const [index, node] of nodes.entries()) {
const bucket = childrenByParent.get(node.parentIndex);
if (bucket === undefined) {
childrenByParent.set(node.parentIndex, [index]);
} else {
bucket.push(index);
}
}
const build = (index: number): TodoTreeNode => {
const node = nodes[index]!;
return {
title: node.summary,
status: node.closed ? 'done' : 'in_progress',
active: index === cursor ? true : undefined,
children: (childrenByParent.get(index) ?? []).map(build),
};
};
return (childrenByParent.get(null) ?? []).map(build);
}
export function spineTreeFromMessages(messages: AppMessage[]): TodoTreeNode[] {
const nodes: SpineNode[] = [];
/** Ancestor chain root → cursor as node indexes; empty at the root epoch. */
const cursorStack: number[] = [];
const pending = new Map<string, { name: SpineControlToolName; args: Record<string, unknown> }>();
for (const msg of messages) {
for (const c of msg.content) {
if (c.type === 'toolUse') {
const name = normalizeToolName(c.toolName);
if (isSpineControlToolName(name)) {
pending.set(c.toolCallId, { name, args: parseArgs(c.input) });
}
continue;
}
if (c.type !== 'toolResult') continue;
const call = pending.get(c.toolCallId);
if (call === undefined) continue;
pending.delete(c.toolCallId);
// Only accepted transitions touch the tree. Core's contract: accepted ⟺
// isError !== true — a rejected transition surfaces its reason as an
// error. The same isError-only check the TUI's live path applies.
if (c.isError) continue;
applyTransition(nodes, cursorStack, call.name, call.args);
}
}
return projectTree(nodes, cursorStack);
}

View file

@ -71,6 +71,7 @@ import { isPlaceholderSessionUsage, toAppEvent } from '../api/daemon/mappers';
import { messagesToTurns } from './messagesToTurns';
import { latestTodos } from './latestTodos';
import { spineTreeFromMessages } from './spineTree';
import { buildSwarmGroups, countSwarmMembers, swarmMembersByToolCall } from './swarmGroups';
import type { SwarmGroup, SwarmMember } from './swarmGroups';
import type {
@ -87,6 +88,7 @@ import type {
Session,
TaskItem,
TaskState,
TodoTreeNode,
TodoView,
UIQuestion,
Workspace,
@ -652,6 +654,7 @@ async function refreshSessionStatus(sessionId: string): Promise<void> {
usage: {
...s.usage,
contextTokens: st.contextTokens,
rawContextTokens: st.rawContextTokens,
contextLimit: st.maxContextTokens,
},
}));
@ -1911,6 +1914,16 @@ const todos = computed<TodoView[]>(() => {
return latestTodos(rawState.messagesBySession[sid] ?? []);
});
/** Spine task tree of the active session, mirrored from the transcript's
spine control tools. Persists as all-done history after the last close;
empty only when the session never used spine the dock then falls back
to the flat todo list. */
const todoTree = computed<TodoTreeNode[]>(() => {
const sid = rawState.activeSessionId;
if (!sid) return [];
return spineTreeFromMessages(rawState.messagesBySession[sid] ?? []);
});
/** Live compaction state of the active session (present only while running). */
const compaction = computed<CompactionStatus | null>(() => {
const sid = rawState.activeSessionId;
@ -2129,6 +2142,7 @@ const status = computed<ConversationStatus>(() => {
// Raw id for exact comparison in pickers (display name diverges from id).
modelId: matched?.id ?? rawModel,
ctxUsed: activeSession?.usage.contextTokens ?? 0,
ctxRaw: activeSession?.usage.rawContextTokens ?? 0,
ctxMax: activeSession?.usage.contextLimit ?? 0,
permission: rawState.permission,
branch,
@ -2632,6 +2646,7 @@ export function useKimiWebClient() {
* sources a subagent's streaming `outputLines` from here. */
activeAppTasks,
todos,
todoTree,
goal,
swarms,
swarmMembersByToolCallId,

View file

@ -3,6 +3,7 @@ export default {
connectionConnecting: 'Connecting…',
connectionDisconnected: 'Disconnected',
ctxTooltip: 'Used {used} / {max} tokens ({pct}%)',
ctxTooltipRaw: 'Projected {used} · raw {raw} / {max} tokens ({pct}%)',
modelLabel: 'Model',
permissionManual: 'Manual',
permissionAuto: 'Auto',

View file

@ -7,6 +7,7 @@ export default {
dockBash: 'Bash',
dockSubagent: 'Sub Agent',
dockTodos: 'Todos',
dockSpine: 'Spine Tree',
running: 'running',
closePanel: 'Close panel',
timingRunning: 'Running · {time}',

View file

@ -3,6 +3,7 @@ export default {
connectionConnecting: '连接中…',
connectionDisconnected: '未连接',
ctxTooltip: '使用 {used} / {max} tokens ({pct}%)',
ctxTooltipRaw: '投影 {used} · 原始 {raw} / {max} tokens ({pct}%)',
modelLabel: '模型',
permissionManual: '逐条确认',
permissionAuto: '完全自主',

View file

@ -7,6 +7,7 @@ export default {
dockBash: '后台 Bash',
dockSubagent: '子 Agent',
dockTodos: '待办',
dockSpine: 'Spine 树',
running: '运行中',
closePanel: '关闭面板',
timingRunning: '运行中 · {time}',

View file

@ -275,6 +275,19 @@ export interface TodoView {
status: 'pending' | 'in_progress' | 'done';
}
/**
* One node of the Spine task tree mirrored from the transcript (the
* spine_open / spine_close / spine_next control tools). Closed nodes render
* as done, the open cursor chain as in_progress, and the cursor node the
* one the agent is working on right now is flagged `active`.
*/
export interface TodoTreeNode {
title: string;
status: 'in_progress' | 'done';
active?: boolean;
children: TodoTreeNode[];
}
export type TaskState = 'run' | 'done' | 'fail';
export interface TaskItem {
@ -300,6 +313,9 @@ export interface ConversationStatus {
/** Raw model id — the value selection lists compare against. */
modelId: string;
ctxUsed: number;
/** Unfolded ("raw") context size; 0 when the daemon hasn't reported it
* renders then fall back to the single-value projected display. */
ctxRaw: number;
ctxMax: number;
permission: 'manual' | 'auto' | 'yolo';
branch: string;

View file

@ -23,7 +23,6 @@ import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
import { contextSizeMeasured } from '#/agent/contextSize/contextSizeOps';
import { SPINE_FLAG_ID } from '#/agent/spine/flag';
import { IAgentSpineService } from '#/agent/spine/spine';
import { SpineModel, spineRootCompact } from '#/agent/spine/spineOps';
import { IAgentLLMRequesterService, type LLMRequestFinish } from '#/agent/llmRequester/llmRequester';
import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry';
import { IAgentLoopService, type AfterStepContext, type LoopErrorContext } from '#/agent/loop/loop';
@ -794,11 +793,17 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
// appending so the epoch archive records exactly what the model stops
// seeing.
const foldedMessages = this.context.get().slice(0, summaryAt);
// Compute the epoch from the PRE-append derived state and epochStartAt
// arithmetically. Reading either off the post-append stream would make them
// depend on the append landing synchronously — when a tool exchange defers
// it, a stale read would report the CURRENT epoch and archive the new
// epoch's folded history under its path, overwriting it.
const epoch = this.spine.currentState().rootEpoch + 1;
const epochStartAt = summaryAt + 1;
this.context.append(summaryMessage);
const epochStartAt = this.context.get().length;
const tokensAfter = estimateTokensForMessages([summaryMessage]);
const spineState = this.wire.getModel(SpineModel);
const epoch = spineState.rootEpoch + 1;
// The appended summary message IS the epoch boundary: the spine derivation
// reports the new epoch once it lands — no tree op to dispatch.
const archivePath = await this.spine.archiveEpochRoot({
epoch,
epochStartAt,
@ -816,12 +821,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
);
}
this.wire.dispatch(
spineRootCompact({
epoch,
epochStartAt,
epochMemoryAt: summaryAt,
archivePath,
}),
contextSizeMeasured({ length: epochStartAt, tokens: tokensAfter, kind: 'estimate' }),
);
return {
@ -863,7 +862,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
private epochScopedHistory(
history: readonly ContextMessage[],
): readonly ContextMessage[] {
const spineState = this.wire.getModel(SpineModel);
const spineState = this.spine.currentState();
const start = Math.min(spineState.epochStartAt, history.length);
const scoped = history.slice(start);
const summaryAt = spineState.epochMemoryAt;

View file

@ -9,6 +9,7 @@
export * from './flag';
export * from './instructions';
export * from './spine';
export * from './spineDerive';
export * from './spineOps';
export * from './spineService';
export * from './spineTree';

View file

@ -88,14 +88,19 @@ Conventions:
memory in a fresh sibling.
* \`spine_tree\` is read-only; actual transitions happen only through \`spine_open\`,
\`spine_close\`, and \`spine_next\`.
* Spine transitions change task scope, not communication state. A final response,
status update, or user-facing report does not by itself require a \`spine_open\`,
\`spine_next\`, or \`spine_close\` call; never create a reporting node or perform
a transition solely for delivery.
* Root-epoch ids such as \`1\` or \`2\` cannot be closed. The initial \`1.1\` is a
startup work node, not a concrete task node; use \`spine_open\` before doing task work.
* \`<spine_status>\` gives current node orientation; \`<spine_memory>\` gives
continuation memory from closed work.
* \`[U#]\` anchors refer to numbered user requests. When writing memory, preserve
\`[U#]\` anchors and record each request's status. After \`<spine_memory>\`
continuity or a node transition, use that record to report only new results,
blockers, or requested details.
\`[U#]\` anchors for user requests that still matter. Do not maintain a separate
request-status ledger when the relevant intent is already captured in ordinary
continuation state. After \`<spine_memory>\` continuity or a node transition,
report only new results, blockers, or requested details.
* Place user-facing replies where they are most useful: local intermediate
results may wait for later merge, while complete conclusions, blocking status,
or decisions needing user input should be surfaced promptly.

View file

@ -16,6 +16,7 @@ import { createDecorator } from '#/_base/di/instantiation';
import type { ContextMessage } from '#/agent/contextMemory/types';
import type { SpineEpochArchiveInput } from './spineArchive';
import type { SpineState } from './spineOps';
export const SPINE_TOOL_OPEN = 'spine_open';
export const SPINE_TOOL_CLOSE = 'spine_close';
@ -43,15 +44,18 @@ export interface IAgentSpineService {
readonly enabled: boolean;
acceptOpen(summary: string, toolCallId: string): SpineTransitionResult;
acceptClose(memory: string, toolCallId: string): SpineTransitionResult;
acceptNext(summary: string, memory: string, toolCallId: string): SpineTransitionResult;
acceptOpen(summary: string): SpineTransitionResult;
acceptClose(memory: string): SpineTransitionResult;
acceptNext(summary: string, memory: string): SpineTransitionResult;
archiveEpochRoot(input: SpineEpochArchiveInput): Promise<string | undefined>;
renderTree(): string;
fold(messages: readonly ContextMessage[]): readonly ContextMessage[];
/** The current tree state, derived from the message stream on read. */
currentState(): SpineState;
}
export const IAgentSpineService = createDecorator<IAgentSpineService>('agentSpineService');

View file

@ -0,0 +1,236 @@
/**
* `spine` domain (L4) derives the task tree purely from the stored
* `contextMemory` message stream.
*
* The message stream is the single source of truth: a spine control-tool call
* whose accepted receipt landed in history IS the transition no parallel op
* records, no commit protocol, and nothing to repair when the history shrinks
* (an undo that removes a transition's messages removes the transition).
* `deriveSpineState` scans the surviving messages, matches each `spine_open` /
* `spine_close` / `spine_next` call to its accepted receipt the exact
* `ACCEPTED_OUTPUT` carrier text, or the legacy bare `accepted` left by older
* sessions; persisted metadata can degrade, so the match is textual and a
* near-miss does not count and replays the transitions under the same
* guards the legacy ops enforced (cursor position, non-empty bodies, root
* epochs never close). Root-epoch boundaries come from the compaction summary
* message itself (`origin.kind === 'compaction_summary'`, with the summary
* prefix text as the fallback carrier when the origin metadata is absent). A
* closing node's memory body is assembled from the live span user requests
* keep their fold ordinals and already-closed children contribute their
* assembled bodies so an undo that rewrites the span rewrites the memory
* with it. Consumed by `spineService`; the fold projection and archive
* rendering are unchanged.
*
* Silence is the design, not an oversight: a call whose accepted receipt never
* landed, a receipt whose call is missing, or a transition the guards reject
* (a close under a stale cursor, an empty body) simply does not happen, with
* no lost-commit audit and no repair op. The stream is the whole truth, so a
* transition the stream does not fully witness is not a transition the
* legacy op world needed `reportLostCommits` precisely because it kept a
* second record that could disagree with the receipts.
*/
import {
COMPACTION_SUMMARY_PREFIX,
isCompactionSummaryMessage,
} from '#/agent/contextMemory/compactionHandoff';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { SPINE_TOOL_CLOSE, SPINE_TOOL_NEXT, SPINE_TOOL_OPEN } from './spine';
import { collectSpanUserRequests } from './spineFold';
import type { SpineNode, SpineState } from './spineOps';
import {
assembleMemoryBody,
childNodeId,
closedChildMemories,
epochStartupNodeId,
isRootEpoch,
nextChildIndex,
parentNodeId,
SPINE_VOID_OPENED_AT,
} from './spineTree';
import { ACCEPTED_OUTPUT } from './tools/controlResult';
/** Receipt left by sessions predating the delayed-commit receipt wording. */
const LEGACY_ACCEPTED_RECEIPT = 'accepted';
export function deriveSpineState(messages: readonly ContextMessage[]): SpineState {
const accepted = collectAcceptedCallIds(messages);
const nodes: Record<string, SpineNode> = {};
let openStack: readonly string[] = [];
let rootEpoch = 0;
let epochStartAt = 0;
let epochMemoryAt: number | undefined;
function openEpoch(epoch: number, startupOpenedAt: number): void {
const epochId = String(epoch);
const startupId = epochStartupNodeId(epoch);
nodes[epochId] = {
id: epochId,
summary: `root epoch ${String(epoch)}`,
openedAt: SPINE_VOID_OPENED_AT,
children: [startupId],
};
nodes[startupId] = {
id: startupId,
summary: 'startup',
openedAt: startupOpenedAt,
children: [],
};
openStack = [epochId, startupId];
rootEpoch = epoch;
}
function assembleNodeMemory(node: SpineNode, closedAt: number, nodeMemory: string): string {
return assembleMemoryBody({
userRequests: collectSpanUserRequests(messages, node.openedAt, closedAt),
childMemories: closedChildMemories(nodes, node),
nodeMemory,
});
}
function openNode(summary: string, openedAt: number): void {
const parentId = openStack.at(-1);
if (parentId === undefined) return;
const parent = nodes[parentId];
if (parent === undefined || parent.closedAt !== undefined) return;
const trimmed = summary.trim();
if (trimmed.length === 0) return;
const id = childNodeId(parentId, nextChildIndex(parent.children));
nodes[id] = { id, summary: trimmed, openedAt, children: [] };
nodes[parentId] = { ...parent, children: [...parent.children, id] };
openStack = [...openStack, id];
}
function closeNode(memory: string, carrierAt: number): void {
const id = openStack.at(-1);
if (id === undefined || isRootEpoch(id)) return;
const node = nodes[id];
if (node === undefined || node.closedAt !== undefined) return;
const trimmed = memory.trim();
if (trimmed.length === 0) return;
// The span ends BEFORE the assistant message carrying the transition call,
// so the carrier and its receipt stay visible in the parent context.
const closedAt = Math.max(carrierAt - 1, node.openedAt);
nodes[id] = { ...node, closedAt, memory: assembleNodeMemory(node, closedAt, trimmed) };
openStack = openStack.slice(0, -1);
}
function nextNode(summary: string, memory: string, carrierAt: number): void {
const closedId = openStack.at(-1);
if (closedId === undefined || isRootEpoch(closedId)) return;
const closing = nodes[closedId];
if (closing === undefined || closing.closedAt !== undefined) return;
const trimmedSummary = summary.trim();
const trimmedMemory = memory.trim();
if (trimmedSummary.length === 0 || trimmedMemory.length === 0) return;
const parentId = parentNodeId(closedId);
if (parentId === null) return;
const parent = nodes[parentId];
if (parent === undefined) return;
const closedAt = Math.max(carrierAt - 1, closing.openedAt);
const openedId = childNodeId(parentId, nextChildIndex(parent.children));
nodes[closedId] = {
...closing,
closedAt,
memory: assembleNodeMemory(closing, closedAt, trimmedMemory),
};
// The sibling opens right after the closing span — at the carrier's index —
// so the carrier and its receipt ride inside the new sibling's span.
nodes[openedId] = {
id: openedId,
summary: trimmedSummary,
openedAt: closedAt + 1,
children: [],
};
nodes[parentId] = { ...parent, children: [...parent.children, openedId] };
openStack = [...openStack.slice(0, -1), openedId];
}
openEpoch(1, 0);
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (message === undefined) continue;
if (isEpochBoundary(message)) {
openEpoch(rootEpoch + 1, i + 1);
epochStartAt = i + 1;
epochMemoryAt = i;
continue;
}
if (message.role !== 'assistant') continue;
for (const call of message.toolCalls) {
if (!accepted.has(call.id)) continue;
const args = parseTransitionArgs(call.arguments);
if (args === undefined) continue;
if (call.name === SPINE_TOOL_OPEN) {
openNode(args.summary, i);
} else if (call.name === SPINE_TOOL_CLOSE) {
closeNode(args.memory, i);
} else if (call.name === SPINE_TOOL_NEXT) {
nextNode(args.summary, args.memory, i);
}
}
}
return { nodes, openStack, rootEpoch, epochStartAt, epochMemoryAt };
}
interface SpineTransitionArgs {
readonly summary: string;
readonly memory: string;
}
function parseTransitionArgs(raw: string | null | undefined): SpineTransitionArgs | undefined {
if (raw === undefined || raw === null) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null) return undefined;
const record = parsed as Record<string, unknown>;
const summary = record['summary'];
const memory = record['memory'];
return {
summary: typeof summary === 'string' ? summary : '',
memory: typeof memory === 'string' ? memory : '',
};
}
function collectAcceptedCallIds(messages: readonly ContextMessage[]): ReadonlySet<string> {
const spineCallIds = new Set<string>();
for (const message of messages) {
if (message === undefined || message.role !== 'assistant') continue;
for (const call of message.toolCalls) {
if (isSpineTransitionTool(call.name)) spineCallIds.add(call.id);
}
}
const accepted = new Set<string>();
for (const message of messages) {
if (message === undefined || message.role !== 'tool') continue;
const callId = message.toolCallId;
if (callId === undefined || !spineCallIds.has(callId)) continue;
if (message.isError === true) continue;
const text = messageText(message);
if (text === ACCEPTED_OUTPUT || text === LEGACY_ACCEPTED_RECEIPT) accepted.add(callId);
}
return accepted;
}
function isSpineTransitionTool(name: string): boolean {
return name === SPINE_TOOL_OPEN || name === SPINE_TOOL_CLOSE || name === SPINE_TOOL_NEXT;
}
function isEpochBoundary(message: ContextMessage): boolean {
if (message.role !== 'user') return false;
if (isCompactionSummaryMessage(message)) return true;
// Fallback carrier for degraded persistence: only when the origin metadata
// is absent — a message that still carries a non-summary origin is trusted.
if (message.origin !== undefined) return false;
return messageText(message).startsWith(COMPACTION_SUMMARY_PREFIX);
}
function messageText(message: ContextMessage): string {
return message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
}

View file

@ -42,6 +42,7 @@ export interface SpineFoldStatus {
readonly cursorId: string;
readonly summary: string;
readonly parentId: string | null;
readonly parentSummary: string | null;
readonly cursorContext: number;
readonly contextLeft: number | undefined;
/** Per-message estimate of the whole stored history (pre-fold, messages only). */
@ -234,6 +235,8 @@ function prefixFirstText(content: readonly ContentPart[], anchor: string): Conte
function statusMessage(status: SpineFoldStatus): ContextMessage {
const parent = status.parentId === null ? '' : ` parent="${status.parentId}"`;
const parentSummary =
status.parentSummary === null ? '' : ` parent_summary="${escapeAttr(status.parentSummary)}"`;
const cursorContext = ` cursor_context="~${formatTokens(status.cursorContext)}"`;
const contextLeft =
status.contextLeft === undefined ? '' : ` context_left="~${formatTokens(status.contextLeft)}"`;
@ -242,7 +245,7 @@ function statusMessage(status: SpineFoldStatus): ContextMessage {
const projectedContext = ` projected_context="${projectedPrefix}${formatTokens(
status.projectedContext,
)}"`;
const text = `<spine_status cursor="${status.cursorId}" summary="${escapeAttr(status.summary)}"${parent}${cursorContext}${contextLeft}${rawContext}${projectedContext} />`;
const text = `<spine_status cursor="${status.cursorId}" summary="${escapeAttr(status.summary)}"${parent}${parentSummary}${cursorContext}${contextLeft}${rawContext}${projectedContext} />`;
return {
role: 'user',
content: [{ type: 'text', text }],

View file

@ -1,19 +1,18 @@
/**
* `spine` domain (L4) wire Model (`SpineModel`) and the Ops that mutate the
* model-driven task tree (`spine.open` / `spine.close` / `spine.next` /
* `spine.root_compact` / `spine.truncate_repair`).
* `spine` domain (L4) the LEGACY wire Model (`SpineModel`) and its Ops
* (`spine.open` / `spine.close` / `spine.next` / `spine.root_compact` /
* `spine.truncate_repair`), plus the `SpineState` / `SpineNode` types the
* whole domain shares.
*
* Declares the tree as `SpineState` (initial root epoch `1` with an open
* synthetic startup node `1.1`): a node map, the open-node stack (its top is
* the cursor), and the current root-epoch boundary. Every Op's `apply` is a
* pure state transform that returns a NEW reference on a real change and the
* SAME reference when its guard fails (so the wire's reference-equality gate
* stays quiet); guards reject malformed or out-of-order payloads so a replay
* never lands the tree in an inconsistent shape cursor, parent linkage and
* child numbering are derived by the live service, never trusted from a record.
* Node ids and memory bodies live on the records themselves, so `wire.dispatch`
* and `wire.replay` rebuild the same tree. Consumed by the Agent-scope
* `spineService` and the `spineFold` projection.
* Since the derivation rewrite, the live tree is rebuilt from the
* `contextMemory` message stream by `spineDerive.deriveSpineState` and these
* ops are NEVER dispatched: they stay registered only so sessions persisted
* before the rewrite still replay without unknown-op errors, and their
* reducers are kept honest by the `Spine reducers (via wire)` tests. The
* state shape below is the derivation's output contract a node map, the
* open-node stack (its top is the cursor), and the current root-epoch
* boundary, with `openedAt`/`closedAt` indexing the stored history. Consumed
* by the Agent-scope `spineService` and the `spineFold` projection.
*/
import { z } from 'zod';

View file

@ -1,60 +1,44 @@
/**
* `spine` domain (L4) `IAgentSpineService` implementation.
*
* Owns the model-driven task tree in the wire `SpineModel`: the read-only /
* The task tree is DERIVED, not stored: `deriveSpineState` replays the
* `contextMemory` message stream and rebuilds the `SpineState` from the spine
* control-tool calls and their accepted receipts, so the persisted history is
* the single source of truth a receipt that survived IS the transition, and
* a truncation (undo / clear) truncates the tree by construction, with no
* commit dance, no repair op, and no lost-commit audit. The read-only /
* receipt-only control tools hand validated intent here (`acceptOpen` /
* `acceptClose` / `acceptNext`), which registers the single per-step pending
* transition; the `loop.afterStep` hook then commits it (`spine.open` /
* `spine.close` / `spine.next`) once the matching assistant tool-call and tool
* result have both landed in `contextMemory`, so the tree moves only on
* observed evidence. A closing span ends before the assistant message carrying
* the transition call, so the carrier, its receipt, and any slower tool
* results batched in the same response stay visible and paired in the parent
* context; `spine.next` hands the carrier to the new sibling, whose span
* opens right after the closing one. Acceptance validates non-empty bodies
* and the cursor position. At commit the closing span's real user requests are compiled into
* the memory body as `## User Message [U#]` sections (with the fold's stable
* ordinals), so `[U#]` citations stay resolvable after the span folds away.
* Reads the cursor and node layout through
* `wire.getModel(SpineModel)`, writes through `wire.dispatch(spineOpen(...))`
* etc., records each node's provider-token baseline and closing high-water
* mark via `contextSize` surfaced as per-node cost in `spine_tree` and as
* the projected-growth `cursor_context` delta in `<spine_status>`
* assembles continuation memory with `spineTree.assembleMemoryBody`, archives
* each closed node's trajectory under the bootstrap-issued per-agent session
* homedir (`<sessionDir>/agents/<id>/spine/`), and for root compactions
* archives the history the new epoch boundary folds away (`archiveEpochRoot`),
* with the path published back onto the new epoch node so the folded context
* stays one `Read` away. Persistence failures are never swallowed: a failed
* commit dispatch or archive write is reported through `onUnexpectedError`,
* and a node whose archive could not be written still closes, with the failure
* marked in its memory. On restore it audits the rebuilt transcript against
* the committed `spine.*` records read through `wireRecord` every accepted
* control-tool receipt must have its op and reports lost transitions
* (detection only, no repair): ops without receipts (receipts a compaction
* folded away) are the benign direction and stay silent, and the legacy bare
* `accepted` receipt left by older sessions still counts, so resuming them
* raises no false alarm. A transition left pending when its step ends
* (typically an abort before afterStep ran) is committed at the next step's
* start when its receipt already landed, so the tree catches up before the
* model sees the context; one with no receipt to commit against is dropped and
* reported the same way, unless the owning step aborted (a routine interrupt).
* Renders the read-only `spine_tree` view across every
* root epoch (current first by numeric order), so a superseded epoch's
* closed-node archives stay discoverable after a root compaction. Registers
* its history fold into `contextProjector` and its `<spine_view>` prompt block
* into `llmRequester` (spine projector / llmRequester, never the reverse);
* the prompt contribution self-gates per request, so only turn requests whose
* tool list can act on the protocol (i.e. that offer `spine_open`) carry it
* sub-agents and operations such as compaction never see it. Repairs the tree
* when the stored history shrinks beneath it (`context.spliced` with a nonzero
* delete count): an undo truncation clamps straddling closed spans to the cut,
* voids fully-truncated ones, restarts truncated open spans there, and clamps
* the epoch boundary (and its summary anchor) into the surviving range
* (`spine.truncate_repair`) a `/clear` is the cut-at-zero case of the same
* repair so post-truncation messages are never folded against dangling
* indices and the observation cursor (`lastObservedIndex`) is re-anchored to
* the cut. Self-checks the
* `acceptClose` / `acceptNext`); acceptance checks the derived cursor position,
* enforces the single-transition-per-step rule, and records the node's
* provider-token baseline / closing high-water mark via `contextSize`
* surfaced as per-node cost in `spine_tree` and as the projected-growth
* `cursor_context` delta in `<spine_status>`. A closing span ends before the
* assistant message carrying the transition call, so the carrier, its receipt,
* and any slower tool results batched in the same response stay visible and
* paired in the parent context; `spine.next` hands the carrier to the new
* sibling, whose span opens right after the closing one. At close the span's
* real user requests are compiled into the memory body as `## User Message
* [U#]` sections (with the fold's stable ordinals) by the derivation, so
* `[U#]` citations stay resolvable after the span folds away and an undo
* that removes a request removes it from the memory too, since the body is
* re-assembled from the surviving history on every read. Side effects ride
* the derivation delta: the `loop.afterStep` hook archives each newly closed
* node's trajectory under the bootstrap-issued per-agent session homedir
* (`<sessionDir>/agents/<id>/spine/`), and for root compactions the
* full-compaction flow archives the history the new epoch boundary folds away
* (`archiveEpochRoot`). Archive paths are deterministic (`f(homedir, nodeId)`),
* so a restore re-derives them and the first post-restore sweep rewrites any
* archive a crash lost. Persistence failures are never swallowed: a failed
* archive write is reported through `onUnexpectedError`, and the node's memory
* carries the failure note in the projection from then on. Renders the
* read-only `spine_tree` view across every root epoch (current first by
* numeric order), so a superseded epoch's closed-node archives stay
* discoverable after a root compaction. Registers its history fold into
* `contextProjector` and its `<spine_view>` prompt block into `llmRequester`
* (spine projector / llmRequester, never the reverse); the prompt
* contribution self-gates per request, so only turn requests whose tool list
* can act on the protocol (i.e. that offer `spine_open`) carry it sub-agents
* and operations such as compaction never see it. Self-checks the
* `KIMI_CODE_SPINE` gate at construction, so a disabled spine never observes
* history. Bound at Agent scope.
*/
@ -64,6 +48,7 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
import { estimateTokensForMessages } from '#/_base/utils/tokens';
import { COMPACTION_SUMMARY_PREFIX } from '#/agent/contextMemory/compactionHandoff';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector';
@ -72,10 +57,6 @@ import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import {
IAgentWireRecordService,
type PersistedWireRecord,
} from '#/agent/wireRecord/wireRecord';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IEventBus } from '#/app/event/eventBus';
import { IFlagService } from '#/app/flag/flag';
@ -89,8 +70,6 @@ import { SPINE_FLAG_ID } from './flag';
import { appendSpineView, loadSpineViewOverride } from './instructions';
import {
IAgentSpineService,
SPINE_TOOL_CLOSE,
SPINE_TOOL_NEXT,
SPINE_TOOL_OPEN,
type SpineTransitionResult,
} from './spine';
@ -101,18 +80,10 @@ import {
writeNodeArchive,
type SpineEpochArchiveInput,
} from './spineArchive';
import { collectSpanUserRequests, foldSpine, type SpineFoldStatus } from './spineFold';
import { deriveSpineState } from './spineDerive';
import { foldSpine, type SpineFoldStatus } from './spineFold';
import { type SpineNode, type SpineState } from './spineOps';
import {
SpineModel,
spineClose,
spineNext,
spineOpen,
spineTruncateRepair,
type SpineNode,
type SpineState,
} from './spineOps';
import {
assembleMemoryBody,
childNodeId,
isRootEpoch,
nextChildIndex,
@ -120,16 +91,6 @@ import {
renderTree,
type SpineTreeNodeView,
} from './spineTree';
import { ACCEPTED_OUTPUT } from './tools/controlResult';
type SpinePending = {
readonly toolCallId: string;
readonly stepSignal: AbortSignal | undefined;
} & (
| { readonly kind: 'open'; readonly summary: string }
| { readonly kind: 'close'; readonly memory: string }
| { readonly kind: 'next'; readonly summary: string; readonly memory: string }
);
const REJECT_DISABLED: SpineTransitionResult = {
accepted: false,
@ -148,12 +109,40 @@ const REJECT_ROOT_EPOCH: SpineTransitionResult = {
'Root-epoch nodes cannot be closed. Use open to start a child node under the current scope.',
};
const ARCHIVE_FAILURE_NOTE =
'[spine: the trajectory archive for this node could not be written; its detailed history was not persisted.]';
export class AgentSpineService extends Disposable implements IAgentSpineService {
declare readonly _serviceBrand: undefined;
private pending: SpinePending | null = null;
private lastObservedIndex = 0;
private stepSignal: AbortSignal | undefined;
/** Single-transition-per-step gate; set by an accept, cleared at step bounds. */
private transitionThisStep = false;
private cachedMessages: readonly ContextMessage[] | undefined;
private cachedState: SpineState | undefined;
/**
* Ephemeral per-node token gauges, recorded at accept time. Token baselines
* are not in the message stream, so pure derivation cannot recover them
* and `context_size.measured` is live-only (`persist: false`, v1-compat), so
* the measurement history is gone on restore too. Within a session these
* maps are complete and request-caliber (better coverage than the FIFO-64
* snapshot chain); on restore they reset, so pre-restore nodes lose
* `tokenCost` and the cursor's `cursor_context` reads as the full size. That
* overstatement fails SAFE for a compaction-trigger gauge (premature close,
* never overflow). Persisting measurements would fix it but is
* contextSize-domain v1 work, out of spine's scope.
*/
private readonly baselines = new Map<string, number>();
private readonly finals = new Map<string, number>();
/** Closed nodes whose trajectory archive is on disk (or rewritten already). */
private readonly archivedIds = new Set<string>();
/**
* Nodes (or epochs) whose archive write failed. For a work node the failure
* note is patched into its memory; an epoch node carries no memory, so its id
* only suppresses the published archive path the tree never points at a
* missing file, and the failure is reported through `onUnexpectedError`
* either way.
*/
private readonly failedArchiveIds = new Set<string>();
private spineViewOverride: string | undefined;
private spineViewReady: Promise<void> = Promise.resolve();
@ -168,11 +157,10 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
@ISessionContext private readonly sessionCtx: ISessionContext,
@IAgentScopeContext private readonly agentScope: IAgentScopeContext,
@IAgentWireService private readonly wire: IWireService,
@IAgentWireRecordService private readonly wireRecord: IAgentWireRecordService,
@IEventBus private readonly eventBus: IEventBus,
@IAgentLoopService loop: IAgentLoopService,
@IAgentContextProjectorService projector: IAgentContextProjectorService,
@IAgentLLMRequesterService llmRequester: IAgentLLMRequesterService,
@IEventBus private readonly eventBus: IEventBus,
) {
super();
if (this.enabled) {
@ -201,103 +189,104 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
this._register(
loop.hooks.onWillBeginStep.register('spine', async (ctx, next) => {
await this.spineViewReady;
if (this.pending !== null) {
// A leftover pending means the previous step ended (usually an abort
// before afterStep ran) without committing its transition. Commit it
// now — before this step's request is built — so a receipt that
// already landed is honored and the tree catches up before the model
// sees the context; commitPending drops it when there is no evidence.
await this.commitPending();
}
this.stepSignal = ctx.signal;
// A step that ended without its did-finish hook (an abort) may have
// left the gate set; every step starts with a clean transition budget.
this.transitionThisStep = false;
await next();
}),
);
this._register(
loop.hooks.onDidFinishStep.register('spine', async (_ctx, next) => {
await this.commitPending();
this.transitionThisStep = false;
await this.archiveNewlyClosed();
await next();
}),
);
this._register(
this.wire.onRestored(() => {
this.lastObservedIndex = this.context.get().length;
this.pending = null;
if (this.enabled) this.reportLostCommits();
// The restored history re-derives the tree on first read; the ephemeral
// gauges and archive ledger belong to the pre-restore session. Clearing
// the archive ledger makes the first post-restore sweep rewrite every
// closed node's archive — deterministic content, so a crash between a
// close and its sweep self-heals.
this.cachedMessages = undefined;
this.cachedState = undefined;
this.baselines.clear();
this.finals.clear();
this.archivedIds.clear();
this.failedArchiveIds.clear();
}),
);
this._register(
this.eventBus.subscribe('context.spliced', (event) => {
if (!this.enabled) return;
if (event.deleteCount === 0) return;
// The stored history shrank beneath the tree (undo / clear / full
// replacement): re-anchor the observation cursor to the cut and clamp
// dangling spans and the epoch boundary into the surviving range, so
// post-truncation messages are never folded against stale indices.
const cut = event.start;
this.lastObservedIndex = Math.min(this.lastObservedIndex, cut);
// Invariant (today): deleteCount > 0 splices are tail truncations
// (undo) or full clears/replacements at 0, so `start` equals the
// surviving prefix length. A future mid-history deletion would shift
// later messages down and needs index translation, not clamping —
// skip the repair rather than void spans that actually survive.
if (this.context.get().length !== cut) return;
const state = this.state();
if (this.needsTruncateRepair(state, cut)) {
this.wire.dispatch(spineTruncateRepair({ cut }));
}
// A truncation (undo / clear) can make the derivation reuse a node id
// for a DIFFERENT span: a cleared tree restarts numbering at 1.1.1, and
// an undo that removes a close lets the same node close again with a new
// span. The archive ledger is keyed by id, so left alone it would skip
// the reused id and keep publishing the OLD node's archive path. Clear
// it so the next sweep rewrites surviving archives (deterministic
// content — a harmless no-op for unaffected nodes) and archives reused
// ids fresh.
this.archivedIds.clear();
this.failedArchiveIds.clear();
}),
);
}
private needsTruncateRepair(state: SpineState, cut: number): boolean {
if (state.epochStartAt > cut) return true;
if (state.epochMemoryAt !== undefined && state.epochMemoryAt >= cut) return true;
return Object.values(state.nodes).some(
(node) =>
node.openedAt >= 0 &&
(node.openedAt >= cut || (node.closedAt !== undefined && node.closedAt >= cut)),
);
}
get enabled(): boolean {
return this.flags.enabled(SPINE_FLAG_ID);
}
acceptOpen(summary: string, toolCallId: string): SpineTransitionResult {
acceptOpen(summary: string): SpineTransitionResult {
const guard = this.guard();
if (guard !== null) return guard;
const trimmed = summary.trim();
if (trimmed.length === 0) return reject('open summary must not be empty.');
this.pending = { kind: 'open', toolCallId, summary: trimmed, stepSignal: this.stepSignal };
const state = this.derivedState();
const parentId = topOf(state);
const parent = state.nodes[parentId];
if (parent !== undefined) {
this.baselines.set(
childNodeId(parentId, nextChildIndex(parent.children)),
this.contextSize.get().size,
);
}
this.transitionThisStep = true;
return { accepted: true };
}
acceptClose(memory: string, toolCallId: string): SpineTransitionResult {
acceptClose(memory: string): SpineTransitionResult {
const guard = this.guard();
if (guard !== null) return guard;
const trimmed = memory.trim();
if (trimmed.length === 0) return reject('close memory must not be empty.');
if (isRootEpoch(this.cursorId())) return REJECT_ROOT_EPOCH;
this.pending = { kind: 'close', toolCallId, memory: trimmed, stepSignal: this.stepSignal };
const cursorId = this.cursorId();
if (isRootEpoch(cursorId)) return REJECT_ROOT_EPOCH;
this.finals.set(cursorId, this.contextSize.get().size);
this.transitionThisStep = true;
return { accepted: true };
}
acceptNext(summary: string, memory: string, toolCallId: string): SpineTransitionResult {
acceptNext(summary: string, memory: string): SpineTransitionResult {
const guard = this.guard();
if (guard !== null) return guard;
const trimmedSummary = summary.trim();
const trimmedMemory = memory.trim();
if (trimmedSummary.length === 0) return reject('next summary must not be empty.');
if (trimmedMemory.length === 0) return reject('next memory must not be empty.');
if (isRootEpoch(this.cursorId())) return REJECT_ROOT_EPOCH;
this.pending = {
kind: 'next',
toolCallId,
summary: trimmedSummary,
memory: trimmedMemory,
stepSignal: this.stepSignal,
};
const cursorId = this.cursorId();
if (isRootEpoch(cursorId)) return REJECT_ROOT_EPOCH;
const state = this.derivedState();
const parentId = parentNodeId(cursorId);
const parent = parentId === null ? undefined : state.nodes[parentId];
const sizeNow = this.contextSize.get().size;
this.finals.set(cursorId, sizeNow);
if (parentId !== null && parent !== undefined) {
this.baselines.set(childNodeId(parentId, nextChildIndex(parent.children)), sizeNow);
}
this.transitionThisStep = true;
return { accepted: true };
}
@ -319,11 +308,16 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
return foldSpine(messages, { state, status: this.buildStatus(), epochSummaryMessage });
}
currentState(): SpineState {
return this.state();
}
private buildStatus(): SpineFoldStatus {
const state = this.state();
const cursorId = topOf(state);
const cursor = state.nodes[cursorId];
const summary = cursor?.summary ?? '';
const summary = state.nodes[cursorId]?.summary ?? '';
const parentId = parentNodeId(cursorId);
const parentSummary = parentId === null ? null : (state.nodes[parentId]?.summary ?? null);
const maxContextTokens = this.profile.getEffectiveMaxContextTokens();
const used = this.contextSize.get().size;
const contextLeft =
@ -333,13 +327,14 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
return {
cursorId,
summary,
parentId: parentNodeId(cursorId),
parentId,
parentSummary,
// Projected-growth caliber: the live gauge minus the node's open
// baseline. The stored-range reading this replaces counted folded-away
// child/sibling spans the model no longer sees, drifting the budget
// signal apart from the compaction trigger's caliber; a node whose
// folds reclaimed more than it added reads as zero.
cursorContext: Math.max(0, used - (cursor?.baselineTokens ?? 0)),
cursorContext: Math.max(0, used - (this.baselines.get(cursorId) ?? 0)),
contextLeft,
rawContext: estimateTokensForMessages(this.context.get()),
projectedContext: used,
@ -347,198 +342,142 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
};
}
private reportLostCommits(): void {
const receipts = countAcceptedReceipts(this.context.get());
const committed = countCommittedOps(this.wireRecord.getRecords());
const lost: string[] = [];
for (const kind of SPINE_TRANSITION_KINDS) {
if (committed[kind] < receipts[kind]) {
lost.push(
`${SPINE_TOOL_NAME[kind]}: ${String(receipts[kind])} accepted receipt(s) vs ${String(committed[kind])} ${SPINE_OP_TYPE[kind]} op(s)`,
);
}
}
if (lost.length === 0) return;
onUnexpectedError(
new Error(
`Spine: lost transition(s) detected on restore — ${lost.join('; ')}. ` +
'A receipt was persisted but the matching tree op was not; the tree may be missing nodes. No automatic repair is attempted.',
),
);
}
private guard(): SpineTransitionResult | null {
if (!this.enabled) return REJECT_DISABLED;
if (this.pending !== null) return REJECT_CONFLICT;
if (this.transitionThisStep) return REJECT_CONFLICT;
return null;
}
/**
* The projection-facing state: the derivation plus the archive-failure note
* patched into the affected nodes' memory, so the model learns from the next
* projection on that the detailed trajectory was not persisted.
*/
private state(): SpineState {
return this.wire.getModel(SpineModel);
const derived = this.derivedState();
if (this.failedArchiveIds.size === 0) return derived;
let nodes: Record<string, SpineNode> | undefined;
for (const id of this.failedArchiveIds) {
const node = derived.nodes[id];
if (node?.memory === undefined) continue;
nodes ??= { ...derived.nodes };
nodes[id] = { ...node, memory: `${node.memory}\n\n${ARCHIVE_FAILURE_NOTE}` };
}
return nodes === undefined ? derived : { ...derived, nodes };
}
private derivedState(): SpineState {
const messages = this.context.get();
// The wire hands back the same array reference until an op mutates it, so
// a reference hit means the derivation is still valid.
if (this.cachedState !== undefined && this.cachedMessages === messages) {
return this.cachedState;
}
const state = deriveSpineState(messages);
this.cachedMessages = messages;
this.cachedState = state;
return state;
}
private cursorId(): string {
const stack = this.state().openStack;
const top = stack.at(-1);
if (top === undefined) {
throw new Error('Spine openStack is empty; the tree must always contain a root epoch.');
}
return top;
return topOf(this.derivedState());
}
private nodeView(state: SpineState, id: string, used: number): SpineTreeNodeView | undefined {
const node = state.nodes[id];
if (node === undefined) return undefined;
const supersededEpoch = isRootEpoch(id) && id !== String(state.rootEpoch);
const epoch = isRootEpoch(id);
const supersededEpoch = epoch && id !== String(state.rootEpoch);
const closed = node.closedAt !== undefined || supersededEpoch;
return {
id: node.id,
summary: node.summary,
closed: node.closedAt !== undefined || supersededEpoch,
archivePath: node.archivePath,
tokenCost: nodeTokenCost(node, used),
closed,
archivePath: this.nodeArchivePath(id, epoch, closed),
tokenCost: nodeTokenCost(node, used, this.baselines, this.finals),
children: node.children
.map((childId) => this.nodeView(state, childId, used))
.filter((child): child is SpineTreeNodeView => child !== undefined),
};
}
private async commitPending(): Promise<void> {
// Archive paths are deterministic, so the tree publishes them without
// persisting anything; a write that failed (this session) suppresses the
// path instead of pointing at a missing file. The epoch archive written
// when epoch N began (holding the prior epochs' folded history) is named
// for N, so epoch 1 predates all archiving and shows none.
private nodeArchivePath(id: string, epoch: boolean, closed: boolean): string | undefined {
if (this.failedArchiveIds.has(id)) return undefined;
if (epoch) return Number(id) > 1 ? this.archivePath(id) : undefined;
return closed ? this.archivePath(id) : undefined;
}
/**
* Projection-delta archiving: every closed node the derivation reports and
* the ledger has not archived yet gets its trajectory written. Runs at step
* end (and effectively on the first step end after a restore, since the
* ledger starts empty), so a close and its archive are at most one step
* apart and a lost write self-heals on the next session.
*/
private async archiveNewlyClosed(): Promise<void> {
if (!this.enabled) return;
const pending = this.pending;
if (pending === null) return;
const history = this.context.get();
// Undo / clear may have shrunk the history below the last observation;
// clamp the search start so a legitimate post-truncation transition is
// not dropped as evidence-less.
const evidence = findEvidence(
history,
Math.min(this.lastObservedIndex, history.length),
pending.toolCallId,
);
this.lastObservedIndex = history.length;
if (evidence === null) {
this.dropPending(pending, 'the step ended without tool-result evidence');
return;
}
try {
switch (pending.kind) {
case 'open':
this.commitOpen(pending.summary, evidence.assistantIndex);
break;
case 'close':
await this.commitClose(pending.memory, evidence.assistantIndex);
break;
case 'next':
await this.commitNext(pending.summary, pending.memory, evidence.assistantIndex);
break;
const state = this.derivedState();
const messages = this.context.get();
for (const node of Object.values(state.nodes)) {
if (node.closedAt === undefined || node.openedAt < 0) continue;
if (this.archivedIds.has(node.id) || this.failedArchiveIds.has(node.id)) continue;
const path = this.archivePath(node.id);
const span = messages.slice(Math.max(0, node.openedAt), node.closedAt + 1);
const content = buildArchiveContent({ node, messages: span });
try {
await writeNodeArchive(this.hostFs, path, content);
this.archivedIds.add(node.id);
} catch (error) {
onUnexpectedError(error);
this.failedArchiveIds.add(node.id);
}
} catch (error) {
onUnexpectedError(
new Error(
`Spine: failed to commit ${pending.kind} transition (toolCallId ${pending.toolCallId}); dropping the transition.`,
{ cause: error },
),
);
} finally {
this.pending = null;
}
await this.archiveCurrentEpochBoundary(state, messages);
}
private dropPending(pending: SpinePending, why: string): void {
this.pending = null;
if (pending.stepSignal?.aborted === true) return;
onUnexpectedError(
new Error(
`Spine: dropping ${pending.kind} transition (toolCallId ${pending.toolCallId}) — ${why}. The accepted receipt stays in the transcript.`,
),
);
}
private commitOpen(summary: string, openedAt: number): void {
const state = this.state();
const parentId = topOf(state);
const parent = state.nodes[parentId];
if (parent === undefined) return;
const id = childNodeId(parentId, nextChildIndex(parent.children));
this.wire.dispatch(
spineOpen({
id,
summary,
parentId,
openedAt,
baselineTokens: this.contextSize.get().size,
}),
);
}
private async commitClose(memory: string, assistantIndex: number): Promise<void> {
const state = this.state();
const id = topOf(state);
const node = state.nodes[id];
if (node === undefined || isRootEpoch(id)) return;
// The span ends BEFORE the assistant message carrying the transition
// call: the carrier, its instant receipt, and any slower tool results
// batched in the same response stay visible and paired in the parent
// context. (Closing at the receipt index would fold the carrier away and
// orphan the late results — the receipt always lands first.) The max()
// guards a span start that truncation repair moved past the carrier's
// predecessor.
const closedAt = Math.max(assistantIndex - 1, node.openedAt);
const assembled = assembleMemoryBody({
userRequests: collectSpanUserRequests(this.context.get(), node.openedAt, closedAt),
childMemories: closedChildMemories(state, node),
nodeMemory: memory,
/**
* The current epoch's boundary archive is written by the full-compaction
* flow when the epoch begins, but that write is a side effect the ledger
* does not retry: a transient failure (or a crash mid-write) leaves the file
* missing, and a later restore clears the failure ledger so the tree
* publishes the path again pointing at a file that was never written.
* Reconstruct it here from the derived boundary (the summary message and the
* pre-boundary history are both in the surviving stream) so the published
* path always names a real file. Only the CURRENT epoch is reconstructible
* the derived state carries its boundary, not older epochs', whose archives
* their own compactions already wrote.
*/
private async archiveCurrentEpochBoundary(
state: SpineState,
messages: readonly ContextMessage[],
): Promise<void> {
const epoch = state.rootEpoch;
if (epoch <= 1) return;
const id = String(epoch);
if (this.archivedIds.has(id) || this.failedArchiveIds.has(id)) return;
const memoryAt = state.epochMemoryAt;
if (memoryAt === undefined) return;
const summaryMessage = messages[memoryAt];
if (summaryMessage === undefined) return;
const content = buildEpochArchiveContent({
epoch,
epochStartAt: state.epochStartAt,
epochMemoryAt: memoryAt,
summary: stripCompactionSummaryPrefix(messageText(summaryMessage)),
messages: messages.slice(0, memoryAt),
});
const closing: SpineNode = { ...node, closedAt, memory: assembled };
const archivePath = await this.archiveNode(closing);
this.wire.dispatch(
spineClose({
id,
closedAt,
memory: markArchiveFailure(assembled, archivePath),
archivePath,
finalTokens: this.contextSize.get().size,
}),
);
}
private async commitNext(summary: string, memory: string, assistantIndex: number): Promise<void> {
const state = this.state();
const closedId = topOf(state);
const closing = state.nodes[closedId];
if (closing === undefined || isRootEpoch(closedId)) return;
const parentId = parentNodeId(closedId);
if (parentId === null) return;
const parent = state.nodes[parentId];
if (parent === undefined) return;
const openedId = childNodeId(parentId, nextChildIndex(parent.children));
// Same boundary rule as commitClose: the span ends before the transition
// carrier, and the reducer opens the new sibling right after (at the
// carrier's index), so the carrier and its receipt ride inside the new
// sibling's span.
const closedAt = Math.max(assistantIndex - 1, closing.openedAt);
const assembled = assembleMemoryBody({
userRequests: collectSpanUserRequests(this.context.get(), closing.openedAt, closedAt),
childMemories: closedChildMemories(state, closing),
nodeMemory: memory,
});
const closed: SpineNode = { ...closing, closedAt, memory: assembled };
const archivePath = await this.archiveNode(closed);
const sizeNow = this.contextSize.get().size;
this.wire.dispatch(
spineNext({
closedId,
closedAt,
memory: markArchiveFailure(assembled, archivePath),
archivePath,
finalTokens: sizeNow,
openedId,
summary,
baselineTokens: sizeNow,
}),
);
try {
await writeNodeArchive(this.hostFs, this.archivePath(id), content);
this.archivedIds.add(id);
} catch (error) {
onUnexpectedError(error);
this.failedArchiveIds.add(id);
}
}
async archiveEpochRoot(input: SpineEpochArchiveInput): Promise<string | undefined> {
@ -547,24 +486,11 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
const content = buildEpochArchiveContent(input);
try {
await writeNodeArchive(this.hostFs, path, content);
this.archivedIds.add(String(input.epoch));
return path;
} catch (error) {
onUnexpectedError(error);
return undefined;
}
}
private async archiveNode(node: SpineNode): Promise<string | undefined> {
const path = this.archivePath(node.id);
const openedAt = Math.max(0, node.openedAt);
const closedAt = node.closedAt ?? node.openedAt;
const messages = this.context.get().slice(openedAt, closedAt + 1);
const content = buildArchiveContent({ node, messages });
try {
await writeNodeArchive(this.hostFs, path, content);
return path;
} catch (error) {
onUnexpectedError(error);
this.failedArchiveIds.add(String(input.epoch));
return undefined;
}
}
@ -586,7 +512,7 @@ export class AgentSpineService extends Disposable implements IAgentSpineService
function epochRootIds(state: SpineState): readonly string[] {
return Object.keys(state.nodes)
.filter((id) => isRootEpoch(id))
.sort((a, b) => Number(a) - Number(b));
.toSorted((a, b) => Number(a) - Number(b));
}
function topOf(state: SpineState): string {
@ -602,141 +528,32 @@ function topOf(state: SpineState): string {
// gauge while still open). Folds committed inside the node can make the net
// negative — the tree view only needs the "no lasting cost" signal there, so
// it clamps at zero. Nodes without a recorded baseline (root epochs, startup
// nodes, records predating the baseline) render no cost.
function nodeTokenCost(node: SpineNode, currentUsed: number): number | undefined {
const baseline = node.baselineTokens;
// nodes, sessions restored before the gauges were recorded) render no cost.
function nodeTokenCost(
node: SpineNode,
currentUsed: number,
baselines: ReadonlyMap<string, number>,
finals: ReadonlyMap<string, number>,
): number | undefined {
const baseline = baselines.get(node.id);
if (baseline === undefined) return undefined;
const end = node.closedAt === undefined ? currentUsed : node.finalTokens;
const end = node.closedAt === undefined ? currentUsed : finals.get(node.id);
if (end === undefined) return undefined;
return Math.max(0, end - baseline);
}
function closedChildMemories(state: SpineState, node: SpineNode): readonly string[] {
const bodies: string[] = [];
for (const childId of node.children) {
const child = state.nodes[childId];
if (child !== undefined && child.closedAt !== undefined && child.memory !== undefined) {
bodies.push(child.memory);
}
}
return bodies;
}
interface SpineEvidence {
readonly assistantIndex: number;
readonly toolResultIndex: number;
}
function findEvidence(
history: readonly ContextMessage[],
from: number,
toolCallId: string,
): SpineEvidence | null {
let assistantIndex = -1;
for (let i = from; i < history.length; i++) {
const message = history[i];
if (message === undefined) continue;
if (
assistantIndex < 0 &&
message.role === 'assistant' &&
message.toolCalls.some((call) => call.id === toolCallId)
) {
assistantIndex = i;
}
if (message.role === 'tool' && message.toolCallId === toolCallId) {
return assistantIndex < 0 ? null : { assistantIndex, toolResultIndex: i };
}
}
return null;
}
type SpineTransitionKind = 'open' | 'close' | 'next';
const SPINE_TRANSITION_KINDS: readonly SpineTransitionKind[] = ['open', 'close', 'next'];
const SPINE_TOOL_NAME: Record<SpineTransitionKind, string> = {
open: SPINE_TOOL_OPEN,
close: SPINE_TOOL_CLOSE,
next: SPINE_TOOL_NEXT,
};
const SPINE_OP_TYPE: Record<SpineTransitionKind, string> = {
open: 'spine.open',
close: 'spine.close',
next: 'spine.next',
};
const LEGACY_ACCEPTED_RECEIPT = 'accepted';
const ARCHIVE_FAILURE_NOTE =
'[spine: the trajectory archive for this node could not be written; its detailed history was not persisted.]';
function markArchiveFailure(memory: string, archivePath: string | undefined): string {
return archivePath === undefined ? `${memory}\n\n${ARCHIVE_FAILURE_NOTE}` : memory;
}
function countAcceptedReceipts(
messages: readonly ContextMessage[],
): Record<SpineTransitionKind, number> {
const counts: Record<SpineTransitionKind, number> = { open: 0, close: 0, next: 0 };
const kindsByCallId = new Map<string, SpineTransitionKind>();
for (const message of messages) {
if (message.role !== 'assistant') continue;
for (const call of message.toolCalls) {
const kind = kindOfToolName(call.name);
if (kind !== undefined) kindsByCallId.set(call.id, kind);
}
}
for (const message of messages) {
if (message.role !== 'tool' || message.toolCallId === undefined || message.isError === true) {
continue;
}
const kind = kindsByCallId.get(message.toolCallId);
if (kind === undefined) continue;
const text = toolMessageText(message);
if (text !== ACCEPTED_OUTPUT && text !== LEGACY_ACCEPTED_RECEIPT) continue;
counts[kind]++;
}
return counts;
}
function countCommittedOps(
records: readonly PersistedWireRecord[],
): Record<SpineTransitionKind, number> {
const counts: Record<SpineTransitionKind, number> = { open: 0, close: 0, next: 0 };
for (const record of records) {
switch (record.type) {
case 'spine.open':
counts.open++;
break;
case 'spine.close':
counts.close++;
break;
case 'spine.next':
counts.next++;
break;
}
}
return counts;
}
function kindOfToolName(name: string): SpineTransitionKind | undefined {
switch (name) {
case SPINE_TOOL_OPEN:
return 'open';
case SPINE_TOOL_CLOSE:
return 'close';
case SPINE_TOOL_NEXT:
return 'next';
default:
return undefined;
}
}
function toolMessageText(message: ContextMessage): string {
function messageText(message: ContextMessage): string {
return message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
}
// The boundary archive stores the raw summary; the summary message carries it
// under the compaction prefix, so strip the carrier (and its separating
// newline) when reconstructing the archive from the stream.
function stripCompactionSummaryPrefix(text: string): string {
if (!text.startsWith(COMPACTION_SUMMARY_PREFIX)) return text;
return text.slice(COMPACTION_SUMMARY_PREFIX.length).replace(/^\n+/, '');
}
function reject(reason: string): SpineTransitionResult {
return { accepted: false, reason };
}

View file

@ -13,10 +13,13 @@
* closable) and work nodes whose closed span a truncation repair voided. The
* startup node does NOT use it it opens at the real epoch boundary and
* closes like any other work node. Holds no state and performs no IO;
* consumed by `spineOps` (reducers), `spineService` (commit orchestration)
* and `spineFold` (projection).
* consumed by `spineOps` (reducers), `spineDerive` (message-stream
* derivation), `spineService` (commit orchestration) and `spineFold`
* (projection).
*/
import type { SpineNode } from './spineOps';
export const SPINE_VOID_OPENED_AT = -1;
export function nodeDepth(id: string): number {
@ -71,6 +74,26 @@ export function assembleMemoryBody(input: SpineMemoryAssemblyInput): string {
return sections.join('\n\n');
}
/**
* Assembled memory bodies of a node's already-closed children, in child
* order the `## Child Memory` section input for the parent's own assembly.
* Children close before their parent by construction (a close pops the
* cursor), so every child is closed when the parent's memory assembles.
*/
export function closedChildMemories(
nodes: Readonly<Record<string, SpineNode>>,
node: SpineNode,
): readonly string[] {
const bodies: string[] = [];
for (const childId of node.children) {
const child = nodes[childId];
if (child !== undefined && child.closedAt !== undefined && child.memory !== undefined) {
bodies.push(child.memory);
}
}
return bodies;
}
export interface SpineTreeNodeView {
readonly id: string;
readonly summary: string;

View file

@ -43,7 +43,7 @@ export class SpineCloseTool implements BuiltinTool<SpineCloseInput> {
return {
approvalRule: this.name,
description: 'Close the current Spine node',
execute: async (ctx) => toControlResult(this.spine.acceptClose(input.memory, ctx.toolCallId)),
execute: async (_ctx) => toControlResult(this.spine.acceptClose(input.memory)),
};
}
}

View file

@ -49,8 +49,8 @@ export class SpineNextTool implements BuiltinTool<SpineNextInput> {
return {
approvalRule: this.name,
description: 'Finish this node and open the next sibling',
execute: async (ctx) =>
toControlResult(this.spine.acceptNext(input.summary, input.memory, ctx.toolCallId)),
execute: async (_ctx) =>
toControlResult(this.spine.acceptNext(input.summary, input.memory)),
};
}
}

View file

@ -43,7 +43,7 @@ export class SpineOpenTool implements BuiltinTool<SpineOpenInput> {
return {
approvalRule: this.name,
description: 'Open a Spine child node',
execute: async (ctx) => toControlResult(this.spine.acceptOpen(input.summary, ctx.toolCallId)),
execute: async (_ctx) => toControlResult(this.spine.acceptOpen(input.summary)),
};
}
}

View file

@ -176,6 +176,7 @@ export class SessionLegacyService implements ISessionLegacyService {
plan_mode: planData !== null,
swarm_mode: swarm.isActive,
context_tokens: tokens,
raw_context_tokens: contextSize.rawSize(),
max_context_tokens: maxTokens,
context_usage: maxTokens > 0 ? tokens / maxTokens : 0,
};

View file

@ -51,13 +51,37 @@ import {
} from '#/index';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
import { spineClose, spineOpen } from '#/agent/spine/spineOps';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { ACCEPTED_OUTPUT } from '#/agent/spine/tools/controlResult';
import { IAgentWireService } from '#/wire/tokens';
import { IAgentGoalService } from '#/agent/goal/goal';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
type GenerateFn = NonNullable<TestAgentOptions['generate']>;
// Appends a real spine transition — the carrier assistant call and its
// accepted receipt — so the derivation rebuilds the node from the messages.
function spineTransition(
ctx: TestAgentContext,
id: string,
name: string,
args: Record<string, unknown>,
): void {
const carrier: ContextMessage = {
role: 'assistant',
content: [{ type: 'text', text: `calling ${name}` }],
toolCalls: [{ type: 'function', id, name, arguments: JSON.stringify(args) }],
};
const receipt: ContextMessage = {
role: 'tool',
content: [{ type: 'text', text: ACCEPTED_OUTPUT }],
toolCalls: [],
toolCallId: id,
};
ctx.context.append(carrier);
ctx.context.append(receipt);
}
const CATALOGUED_PROVIDER = {
type: 'kimi',
apiKey: 'test-key',
@ -2921,17 +2945,29 @@ describe('FullCompaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: { ...CATALOGUED_MODEL_CAPABILITIES, max_context_tokens: window },
});
for (let i = 0; i < 10; i++) {
// A real closed node: the open carrier rides in first, and the close
// carrier lands before the last two exchanges so the undo below leaves
// it (and the node's closed span) intact. The user prompts stay tiny so
// the assembled memory (which cites the span's requests) stays small and
// the folded view stays far below the raw history the assistants inflate.
spineTransition(ctx, 's_open', 'spine_open', { summary: 'seed node' });
for (let i = 0; i < 8; i++) {
ctx.appendExchange(
i + 1,
`u${String(i)} ${'x'.repeat(2000)}`,
`u${String(i)}`,
`a${String(i)} ${'y'.repeat(2000)}`,
1000,
);
}
spineTransition(ctx, 's_close', 'spine_close', { memory: 'seed memory' });
for (let i = 8; i < 10; i++) {
ctx.appendExchange(
i + 1,
`u${String(i)}`,
`a${String(i)} ${'y'.repeat(2000)}`,
1000,
);
}
const wire = ctx.get(IAgentWireService);
wire.dispatch(spineOpen({ id: '1.1.1', parentId: '1.1', summary: 'seed node', openedAt: 0 }));
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 15, memory: 'seed memory' }));
await ctx.rpc.undoHistory({ count: 1 });
@ -2969,7 +3005,17 @@ describe('FullCompaction', () => {
});
// ~140k estimated raw tokens — above 50% of the 256k window — while the
// folded view (all but the last two exchanges closed) stays tiny.
for (let i = 0; i < 70; i++) {
spineTransition(ctx, 's_open', 'spine_open', { summary: 'seed node' });
for (let i = 0; i < 68; i++) {
ctx.appendExchange(
i + 1,
`u${String(i)} ${'x'.repeat(4000)}`,
`a${String(i)} ${'y'.repeat(4000)}`,
1000,
);
}
spineTransition(ctx, 's_close', 'spine_close', { memory: 'seed memory' });
for (let i = 68; i < 70; i++) {
ctx.appendExchange(
i + 1,
`u${String(i)} ${'x'.repeat(4000)}`,
@ -2977,9 +3023,6 @@ describe('FullCompaction', () => {
1000,
);
}
const wire = ctx.get(IAgentWireService);
wire.dispatch(spineOpen({ id: '1.1.1', parentId: '1.1', summary: 'seed node', openedAt: 0 }));
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 135, memory: 'seed memory' }));
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'go' }] });
const events = await ctx.untilTurnEnd();

View file

@ -75,7 +75,7 @@ describe('Session legacy status (best-effort runtime state)', () => {
kind: LifecycleScope.Agent,
accessor: accessor([
[IAgentProfileService, profile],
[IAgentContextSizeService, { get: () => ({ size: 25, measured: 20, estimated: 5 }) }],
[IAgentContextSizeService, { get: () => ({ size: 25, measured: 20, estimated: 5 }), rawSize: () => 30 }],
[IAgentPermissionModeService, { mode: 'manual' }],
[IAgentPlanService, { status: () => Promise.resolve(null) }],
[IAgentSwarmService, { isActive: false }],

View file

@ -5,9 +5,6 @@ import {
AGENT_WIRE_PROTOCOL_VERSION,
IAgentSpineService,
IAgentWireService,
SpineModel,
spineClose,
spineOpen,
type PersistedWireRecord,
} from '#/index';
@ -56,19 +53,21 @@ describe('Spine archive + resume', () => {
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'start' }] });
await ctx.untilTurnEnd();
const node = readSpine(ctx).nodes['1.1.1'];
expect(node?.archivePath).toBeDefined();
const archivePath = node?.archivePath as string;
expect(readSpine(ctx).nodes['1.1.1']?.closedAt).toBeDefined();
// Archives live under the per-agent session homedir, not the project
// workDir: `<sessionDir>/agents/<id>/spine/<node-id>.md`.
expect(archivePath.endsWith('/agents/main/spine/1-1-1.md')).toBe(true);
expect(writes.has(archivePath)).toBe(true);
const content = writes.get(archivePath) ?? '';
// workDir: `<sessionDir>/agents/<id>/spine/<node-id>.md`. The path is
// deterministic and published on the tree view; the node itself carries
// no persisted path any more.
const archivePath = [...writes.keys()].find((path) =>
path.endsWith('/agents/main/spine/1-1-1.md'),
);
expect(archivePath).toBeDefined();
const content = writes.get(archivePath!) ?? '';
expect(content).toContain('did A');
expect(content).toContain('task A');
expect(content).toContain('## Trajectory');
expect(ctx.get(IAgentSpineService).renderTree()).toContain(archivePath);
expect(ctx.get(IAgentSpineService).renderTree()).toContain(archivePath!);
});
it('replays the tree (with memory and archive path) from persisted wire records', async () => {
@ -77,17 +76,13 @@ describe('Spine archive + resume', () => {
execEnvServices({ hostFs: recordingHostFs(new Map()) }),
wireRecordPersistenceServices(persistence),
);
await configureLoop(ctx);
ctx.mockNextResponse(toolCallPart('c_open', 'spine_open', { summary: 'task A' }));
ctx.mockNextResponse(toolCallPart('c_close', 'spine_close', { memory: 'did A' }));
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'start' }] });
await ctx.untilTurnEnd();
const wire = ctx.get(IAgentWireService);
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
wire.dispatch(
spineClose({
id: '1.1.1',
closedAt: 5,
memory: 'did A',
archivePath: '/ws/spine/main/1-1-1.md',
}),
);
// Upstream's wire persists through an async persistQueue (blob dehydrate
// hop runs even without blobs) that only drains on the wire service's own
// flush; the wireRecord service flush no longer covers it, so cloning the
@ -107,12 +102,11 @@ describe('Spine archive + resume', () => {
const after = readSpine(resumed);
expect(after.openStack).toEqual(before.openStack);
expect(after.rootEpoch).toBe(before.rootEpoch);
expect(after.nodes['1.1.1']).toMatchObject({
summary: 'task A',
memory: 'did A',
closedAt: 5,
archivePath: '/ws/spine/main/1-1-1.md',
});
expect(after.nodes['1.1.1']?.summary).toBe('task A');
expect(after.nodes['1.1.1']?.closedAt).toBe(before.nodes['1.1.1']?.closedAt);
expect(after.nodes['1.1.1']?.memory).toContain('did A');
// The archive path is recomputed deterministically on the tree view.
expect(resumed.get(IAgentSpineService).renderTree()).toContain('1-1-1.md');
});
});
@ -122,7 +116,7 @@ async function configureLoop(ctx: TestAgentContext): Promise<void> {
}
function readSpine(ctx: TestAgentContext) {
return ctx.get(IAgentWireService).getModel(SpineModel);
return ctx.get(IAgentSpineService).currentState();
}
function recordingHostFs(writes: Map<string, string>) {

View file

@ -1,14 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MASTER_ENV } from '#/app/flag/flagService';
import {
IAgentSpineService,
IAgentWireService,
SpineModel,
spineClose,
spineOpen,
spineRootCompact,
} from '#/index';
buildCompactionSummaryText,
createCompactionSummaryMessage,
} from '#/agent/contextMemory/compactionHandoff';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { MASTER_ENV } from '#/app/flag/flagService';
import { ACCEPTED_OUTPUT, IAgentSpineService } from '#/index';
import {
execEnvServices,
@ -63,7 +61,9 @@ describe('Spine / compaction interaction', () => {
await completed;
const recordTypes = ctx.recordHistory.map((record) => record.type);
expect(recordTypes).toContain('spine.root_compact');
// The derivation reads the boundary from the summary message itself — no
// tree op is dispatched for a root compaction any more.
expect(recordTypes).not.toContain('spine.root_compact');
expect(recordTypes).not.toContain('context.apply_compaction');
const state = readSpine(ctx);
@ -96,13 +96,13 @@ describe('Spine / compaction interaction', () => {
await ctx.rpc.beginCompaction({});
await completed;
const state = readSpine(ctx);
const epochNode = state.nodes[String(state.rootEpoch)];
expect(epochNode?.archivePath).toBeDefined();
const archivePath = epochNode?.archivePath as string;
expect(archivePath.endsWith('/agents/main/spine/2.md')).toBe(true);
expect(writes.has(archivePath)).toBe(true);
const content = writes.get(archivePath) ?? '';
// The archive path is deterministic and published on the tree view; the
// epoch node itself carries no persisted path any more.
const archivePath = [...writes.keys()].find((path) =>
path.endsWith('/agents/main/spine/2.md'),
);
expect(archivePath).toBeDefined();
const content = writes.get(archivePath!) ?? '';
expect(content).toContain('# Spine Root Epoch 2');
expect(content).toContain('## Epoch Summary');
expect(content).toContain('Summary.');
@ -112,25 +112,7 @@ describe('Spine / compaction interaction', () => {
expect(content).toContain('recent user');
expect(content).toContain('recent assistant');
expect(ctx.get(IAgentSpineService).renderTree()).toContain(archivePath);
});
it('keeps the epoch archive path on the new epoch node through dispatch', () => {
const ctx = testAgent();
const wire = ctx.get(IAgentWireService);
wire.dispatch(
spineRootCompact({
epoch: 2,
epochStartAt: 10,
epochMemoryAt: 9,
archivePath: '/work/spine/agent-0/2.md',
}),
);
const state = readSpine(ctx);
expect(state.nodes['2']?.archivePath).toBe('/work/spine/agent-0/2.md');
expect(ctx.get(IAgentSpineService).renderTree()).toContain('archive: /work/spine/agent-0/2.md');
expect(ctx.get(IAgentSpineService).renderTree()).toContain(archivePath!);
});
it('completes the root compaction without an archive path when the archive write fails', async () => {
@ -152,10 +134,13 @@ describe('Spine / compaction interaction', () => {
await completed;
const recordTypes = ctx.recordHistory.map((record) => record.type);
expect(recordTypes).toContain('spine.root_compact');
expect(recordTypes).not.toContain('spine.root_compact');
expect(recordTypes).toContain('full_compaction.complete');
const state = readSpine(ctx);
expect(state.nodes[String(state.rootEpoch)]?.archivePath).toBeUndefined();
// The failed epoch archive write is tracked, so the tree view publishes no
// path for the new epoch (instead of pointing at a missing file).
const tree = ctx.get(IAgentSpineService).renderTree();
expect(tree).toContain('2 [open]');
expect(tree).not.toContain('2.md');
expect(
logEntries.some(
(entry) => entry.level === 'warn' && entry.message.toLowerCase().includes('archive'),
@ -165,26 +150,23 @@ describe('Spine / compaction interaction', () => {
it('keeps previous epochs and their archive paths reachable in the tree after a root compaction', () => {
const ctx = testAgent();
const wire = ctx.get(IAgentWireService);
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
wire.dispatch(
spineClose({
id: '1.1.1',
closedAt: 5,
memory: 'did A',
archivePath: '/work/spine/agent-0/1-1-1.md',
}),
);
wire.dispatch(spineRootCompact({ epoch: 2, epochStartAt: 10, epochMemoryAt: 9 }));
// Epoch 1: a real open + close, then the epoch boundary lands.
append(ctx, assistantToolCall('c_open', 'spine_open', JSON.stringify({ summary: 'task A' })));
append(ctx, spineAcceptedReceipt('c_open'));
append(ctx, assistantToolCall('c_close', 'spine_close', JSON.stringify({ memory: 'did A' })));
append(ctx, spineAcceptedReceipt('c_close'));
append(ctx, createCompactionSummaryMessage(buildCompactionSummaryText('epoch summary')));
const tree = ctx.get(IAgentSpineService).renderTree();
expect(tree).toContain('1 [closed]');
expect(tree).toContain('1.1.1');
expect(tree).toContain('task A');
expect(tree).toContain('archive: /work/spine/agent-0/1-1-1.md');
expect(tree).toContain('2 [open]');
// The closed node's archive path is recomputed deterministically and
// published on the tree view.
expect(tree).toContain('archive:');
expect(tree).toContain('1-1-1.md');
expect(tree).toContain('2 [open, archive:');
});
it('summarizes only the current epoch and chains the previous epoch summary', async () => {
@ -226,7 +208,30 @@ describe('Spine / compaction interaction', () => {
});
function readSpine(ctx: TestAgentContext) {
return ctx.get(IAgentWireService).getModel(SpineModel);
return ctx.get(IAgentSpineService).currentState();
}
function append(ctx: TestAgentContext, message: ContextMessage): number {
const index = ctx.context.get().length;
ctx.context.append(message);
return index;
}
function assistantToolCall(id: string, name: string, args: string = '{}'): ContextMessage {
return {
role: 'assistant',
content: [{ type: 'text', text: `calling ${name}` }],
toolCalls: [{ type: 'function', id, name, arguments: args }],
};
}
function spineAcceptedReceipt(toolCallId: string): ContextMessage {
return {
role: 'tool',
content: [{ type: 'text', text: ACCEPTED_OUTPUT }],
toolCalls: [],
toolCallId,
};
}
function recordingHostFs(writes: Map<string, string>) {

File diff suppressed because it is too large Load diff

View file

@ -6,12 +6,6 @@ import {
resetUnexpectedErrorHandler,
setUnexpectedErrorHandler,
} from '#/_base/errors/unexpectedError';
import { Disposable } from '#/_base/di/lifecycle';
import {
IAgentLoopService,
type AfterStepContext,
type BeforeStepContext,
} from '#/agent/loop/loop';
import { ACCEPTED_OUTPUT, toControlResult } from '#/agent/spine/tools/controlResult';
import { SPINE_FLAG_ID } from '#/agent/spine/flag';
import { SpineCloseTool } from '#/agent/spine/tools/spine-close';
@ -23,11 +17,8 @@ import { IFlagService } from '#/app/flag/flag';
import { getToolContributions } from '#/agent/toolRegistry/toolContribution';
import type { ServicesAccessor } from '#/_base/di/instantiation';
import type { ContextMessage } from '#/agent/contextMemory/types';
import type { PersistedWireRecord } from '#/agent/wireRecord/wireRecord';
import type { PersistedRecord } from '#/wire/wireService';
import {
IAgentSpineService,
IAgentWireRecordService,
IAgentWireService,
SPINE_VOID_OPENED_AT,
SpineModel,
@ -40,14 +31,11 @@ import {
import type { Message } from '#/app/llmProtocol/message';
import {
agentService,
createCommandRunner,
execEnvServices,
InMemoryWireRecordPersistence,
testAgent,
type TestAgentContext,
type TestAgentOptions,
type TestAgentServiceOverride,
} from '../harness';
const SPINE_ENV = 'KIMI_CODE_SPINE';
@ -103,7 +91,7 @@ describe('Spine reducers (via wire)', () => {
it('starts with an open root epoch and startup node', () => {
const ctx = testAgent();
const state = readSpine(ctx);
const state = readOps(ctx);
expect(state.rootEpoch).toBe(1);
expect(state.openStack).toEqual(['1', '1.1']);
expect(state.nodes['1']?.children).toEqual(['1.1']);
@ -116,7 +104,7 @@ describe('Spine reducers (via wire)', () => {
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
const state = readSpine(ctx);
const state = readOps(ctx);
expect(state.openStack).toEqual(['1', '1.1', '1.1.1']);
expect(state.nodes['1.1']?.children).toEqual(['1.1.1']);
expect(state.nodes['1.1.1']?.summary).toBe('task A');
@ -128,10 +116,10 @@ describe('Spine reducers (via wire)', () => {
const wire = ctx.get(IAgentWireService);
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
const before = readSpine(ctx);
const before = readOps(ctx);
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 5, memory: 'did A' }));
const state = readSpine(ctx);
const state = readOps(ctx);
expect(state.openStack).toEqual(['1', '1.1']);
expect(state.nodes['1.1.1']?.closedAt).toBe(5);
expect(state.nodes['1.1.1']?.memory).toBe('did A');
@ -144,7 +132,7 @@ describe('Spine reducers (via wire)', () => {
wire.dispatch(spineClose({ id: '1.1', closedAt: 3, memory: 'startup done' }));
const state = readSpine(ctx);
const state = readOps(ctx);
expect(state.openStack).toEqual(['1']);
expect(state.nodes['1.1']?.closedAt).toBe(3);
expect(state.nodes['1.1']?.memory).toBe('startup done');
@ -162,7 +150,7 @@ describe('Spine reducers (via wire)', () => {
wire.dispatch(spineTruncateRepair({ cut: 8 }));
const state = readSpine(ctx);
const state = readOps(ctx);
// Straddling span [2, 9]: fold only the surviving prefix.
expect(state.nodes['1.1.1']?.closedAt).toBe(7);
// Span fully inside the truncated range: voided (fold-excluded).
@ -181,11 +169,11 @@ describe('Spine reducers (via wire)', () => {
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 22 }));
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 30, memory: 'did A' }));
wire.dispatch(spineRootCompact({ epoch: 2, epochStartAt: 20, epochMemoryAt: 19 }));
const before = readSpine(ctx);
const before = readOps(ctx);
wire.dispatch(spineTruncateRepair({ cut: 25 }));
const state = readSpine(ctx);
const state = readOps(ctx);
expect(state.epochStartAt).toBe(20);
expect(state.epochMemoryAt).toBe(19);
// Only the straddling span is repaired; the boundary is untouched.
@ -197,32 +185,32 @@ describe('Spine reducers (via wire)', () => {
const ctx = testAgent();
const wire = ctx.get(IAgentWireService);
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 2 }));
const before = readSpine(ctx);
const before = readOps(ctx);
wire.dispatch(spineTruncateRepair({ cut: 8 }));
expect(readSpine(ctx)).toBe(before);
expect(readOps(ctx)).toBe(before);
});
it('rejects closing a root epoch (no-op, same reference)', () => {
const ctx = testAgent();
const wire = ctx.get(IAgentWireService);
const before = readSpine(ctx);
const before = readOps(ctx);
wire.dispatch(spineClose({ id: '1', closedAt: 5, memory: 'nope' }));
expect(readSpine(ctx)).toBe(before);
expect(readOps(ctx)).toBe(before);
});
it('rejects closing a node that is not the cursor', () => {
const ctx = testAgent();
const wire = ctx.get(IAgentWireService);
wire.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
const before = readSpine(ctx);
const before = readOps(ctx);
wire.dispatch(spineClose({ id: '1.1', closedAt: 5, memory: 'nope' }));
expect(readSpine(ctx)).toBe(before);
expect(readOps(ctx)).toBe(before);
});
it('commits next atomically (close cursor, open sibling under the same parent)', () => {
@ -240,7 +228,7 @@ describe('Spine reducers (via wire)', () => {
}),
);
const state = readSpine(ctx);
const state = readOps(ctx);
expect(state.openStack).toEqual(['1', '1.1', '1.1.2']);
expect(state.nodes['1.1.1']?.closedAt).toBe(5);
expect(state.nodes['1.1.1']?.memory).toBe('did A');
@ -254,11 +242,11 @@ describe('Spine reducers (via wire)', () => {
it('rejects opening under a parent that is not the cursor', () => {
const ctx = testAgent();
const wire = ctx.get(IAgentWireService);
const before = readSpine(ctx);
const before = readOps(ctx);
wire.dispatch(spineOpen({ id: '1.2', summary: 'task X', parentId: '1', openedAt: 0 }));
expect(readSpine(ctx)).toBe(before);
expect(readOps(ctx)).toBe(before);
});
});
@ -336,10 +324,10 @@ describe('Spine control tools', () => {
});
it('commits a spine transition after an undo shrank the history', async () => {
// Reproduces the pre-fix defect: undo truncated the context below
// `lastObservedIndex`, so the next transition's evidence search started
// past the end of the history, found nothing, and dropped the transition
// even though the accepted receipt landed in the transcript.
// The pre-fix defect dropped the post-undo transition because the evidence
// search started past the shrunk history. Derivation has no such cursor:
// the tree rebuilds from the surviving stream and the next transition
// nests under the surviving node.
const ctx = loopContext();
await configureLoop(ctx);
for (let i = 0; i < 3; i++) ctx.appendExchange(i + 1, `seed u${i}`, `seed a${i}`, 100);
@ -349,6 +337,10 @@ describe('Spine control tools', () => {
await ctx.untilTurnEnd();
expect(readSpine(ctx).nodes['1.1.1']?.summary).toBe('task A');
// A work turn inside the node, then undo it — the node itself survives.
ctx.mockNextResponse({ type: 'text', text: 'work' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'work a bit' }] });
await ctx.untilTurnEnd();
await ctx.rpc.undoHistory({ count: 1 });
ctx.mockNextResponse(toolCallPart('call_open_2', 'spine_open', { summary: 'task B' }));
@ -361,11 +353,12 @@ describe('Spine control tools', () => {
expect(state.openStack).toEqual(['1', '1.1', '1.1.1', '1.1.1.1']);
});
it('keeps post-undo messages out of a truncated closed span', async () => {
// Reproduces the pre-fix defect: undo cutting into a closed span left its
// indices dangling; the fold emitted the stale memory at `openedAt` and
// jumped past the end of the truncated history, swallowing every message
// appended after the undo — the model never saw the fresh prompt.
it('reopens a closed span when an undo truncates its close evidence', async () => {
// Derivation semantics: the tree is rebuilt from the surviving message
// stream, so an undo that cuts into a closed span removes the close
// transition with its carrier — the node reopens and its memory is gone.
// The fold must still show every surviving and post-undo message (the
// original defect swallowed everything appended after the undo).
let lastRequestText = '';
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
lastRequestText = historyText(history);
@ -373,26 +366,31 @@ describe('Spine control tools', () => {
};
const ctx = testAgent(execEnvServices({ hostFs: recordingHostFs().fs }), { generate });
await configureLoop(ctx);
for (let i = 0; i < 10; i++) ctx.appendExchange(i + 1, `u${String(i)}`, `a${String(i)}`, 100);
const wire = ctx.get(IAgentWireService);
wire.dispatch(spineOpen({ id: '1.1.1', parentId: '1.1', summary: 'old work', openedAt: 2 }));
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 9, memory: 'old memory' }));
ctx.appendExchange(1, 'u0', 'a0', 100);
ctx.context.append(assistantSpineCall('call_open', 'spine_open', { summary: 'old work' }));
ctx.context.append(spineReceipt('call_open'));
for (let i = 1; i < 4; i++) ctx.appendExchange(i + 1, `u${String(i)}`, `a${String(i)}`, 100);
ctx.context.append(assistantSpineCall('call_close', 'spine_close', { memory: 'old memory' }));
ctx.context.append(spineReceipt('call_close'));
for (let i = 4; i < 10; i++) ctx.appendExchange(i + 1, `u${String(i)}`, `a${String(i)}`, 100);
// Cut lands at index 8 — inside the closed span [2, 9].
await ctx.rpc.undoHistory({ count: 6 });
expect(readSpine(ctx).nodes['1.1.1']?.closedAt).toBe(7);
// Cut lands at index 8 — inside the (formerly) closed span [2, 9].
await ctx.rpc.undoHistory({ count: 7 });
expect(readSpine(ctx).nodes['1.1.1']?.closedAt).toBeUndefined();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'FRESH-PROMPT-MARKER' }] });
await ctx.untilTurnEnd();
expect(lastRequestText).toContain('FRESH-PROMPT-MARKER');
expect(lastRequestText).toContain('old memory');
expect(lastRequestText).toContain('u1');
expect(lastRequestText).not.toContain('old memory');
});
it('keeps the rebuilt history visible after /clear with a dangling epoch boundary', async () => {
// Reproduces the pre-fix defect: /clear emptied the context while the tree
// kept its epoch boundary, so the fold dropped every rebuilt message
// (`i < epochStartAt`) and the model saw nothing but the status line.
it('keeps the rebuilt history visible after /clear', async () => {
// Derivation semantics: /clear empties the stored history, so the derived
// tree resets with it — no dangling epoch boundary, no stale nodes — and
// the fold shows everything rebuilt from then on. (The original defect:
// a boundary that outlived the history dropped every rebuilt message.)
let lastRequestText = '';
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
lastRequestText = historyText(history);
@ -401,16 +399,16 @@ describe('Spine control tools', () => {
const ctx = testAgent(execEnvServices({ hostFs: recordingHostFs().fs }), { generate });
await configureLoop(ctx);
for (let i = 0; i < 11; i++) ctx.appendExchange(i + 1, `u${String(i)}`, `a${String(i)}`, 100);
const wire = ctx.get(IAgentWireService);
wire.dispatch(spineRootCompact({ epoch: 2, epochStartAt: 22, epochMemoryAt: 21 }));
await ctx.rpc.clearContext({});
const state = readSpine(ctx);
expect(state.rootEpoch).toBe(1);
expect(state.epochStartAt).toBe(0);
expect(state.epochMemoryAt).toBeUndefined();
// The old epochs stay in the tree for their archives.
expect(state.nodes['2']).toBeDefined();
// The cleared history carries no epoch evidence, so the old epoch nodes
// are gone from the tree (their archives remain on disk).
expect(state.nodes['2']).toBeUndefined();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'AFTER-CLEAR-MARKER' }] });
await ctx.untilTurnEnd();
@ -419,33 +417,26 @@ describe('Spine control tools', () => {
});
it('folds a closed startup node memory into the next projection', async () => {
let lastRequestText = '';
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
lastRequestText = historyText(history);
return textResult('answer');
};
const ctx = testAgent(execEnvServices({ hostFs: recordingHostFs().fs }), { generate });
const ctx = loopContext();
await configureLoop(ctx);
ctx.mockNextResponse(
toolCallPart('call_close', 'spine_close', { memory: 'STARTUP-MEMORY-MARKER' }),
);
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'STARTUP-PHASE-PROMPT' }] });
await ctx.untilTurnEnd();
ctx
.get(IAgentWireService)
.dispatch(
spineClose({
id: '1.1',
closedAt: ctx.context.get().length - 1,
memory: 'STARTUP-MEMORY-MARKER',
}),
);
ctx.mockNextResponse({ type: 'text', text: 'answer' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'AFTER-STARTUP-CLOSE' }] });
await ctx.untilTurnEnd();
expect(lastRequestText).toContain('<spine_memory>');
expect(lastRequestText).toContain('STARTUP-MEMORY-MARKER');
expect(lastRequestText).toContain('AFTER-STARTUP-CLOSE');
expect(lastRequestText).not.toContain('STARTUP-PHASE-PROMPT');
const projected = historyText(ctx.project());
expect(projected).toContain('<spine_memory>');
expect(projected).toContain('STARTUP-MEMORY-MARKER');
// The closing span's user request is compiled into the memory body.
expect(projected).toContain('## User Message [U1]');
expect(projected).toContain('STARTUP-PHASE-PROMPT');
expect(projected).toContain('AFTER-STARTUP-CLOSE');
});
it('compiles the closing span user requests into the memory body', async () => {
@ -479,12 +470,15 @@ describe('Spine control tools', () => {
};
const ctx = testAgent(execEnvServices({ hostFs: recordingHostFs().fs }), { generate });
await configureLoop(ctx);
for (let i = 0; i < 5; i++) {
ctx.appendExchange(i + 1, `seed-u${String(i)}`, `seed-a${String(i)}`, 100);
}
const wire = ctx.get(IAgentWireService);
wire.dispatch(spineOpen({ id: '1.1.1', parentId: '1.1', summary: 'old work', openedAt: 2 }));
wire.dispatch(spineClose({ id: '1.1.1', closedAt: 5, memory: 'old memory' }));
ctx.appendExchange(1, 'seed-u0', 'seed-a0', 100);
ctx.context.append(assistantSpineCall('call_open', 'spine_open', { summary: 'old work' }));
ctx.context.append(spineReceipt('call_open'));
ctx.appendExchange(2, 'seed-u1', 'seed-a1', 100);
ctx.context.append(assistantSpineCall('call_close', 'spine_close', { memory: 'old memory' }));
ctx.context.append(spineReceipt('call_close'));
ctx.appendExchange(3, 'seed-u2', 'seed-a2', 100);
ctx.appendExchange(4, 'seed-u3', 'seed-a3', 100);
ctx.appendExchange(5, 'seed-u4', 'seed-a4', 100);
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'NUMBER-CHECK-PROMPT' }] });
await ctx.untilTurnEnd();
@ -493,7 +487,8 @@ describe('Spine control tools', () => {
expect(lastRequestText).toContain('[U4] seed-u3');
expect(lastRequestText).toContain('[U6] NUMBER-CHECK-PROMPT');
expect(lastRequestText).toContain('old memory');
expect(lastRequestText).not.toContain('seed-u1');
// The folded span's request survives as a citation inside the memory body.
expect(lastRequestText).toContain('## User Message [U2]');
});
it('commits next atomically across a single step', async () => {
@ -763,165 +758,6 @@ describe('Spine durability', () => {
expect(reported.some((err) => String(err).includes('disk full'))).toBe(true);
});
it('reports an accepted receipt that lost its committed op on restore', async () => {
const records = await recordSpineTurn();
const stripped = records.filter((record) => record.type !== 'spine.close');
const reported = await restoreAndCaptureReports(stripped);
const spineReports = reported.filter((err) => String(err).includes('Spine:'));
expect(spineReports).toHaveLength(1);
expect(String(spineReports[0])).toContain('spine_close');
});
it('stays quiet on restore when every receipt has its op', async () => {
const reported = await restoreAndCaptureReports(await recordSpineTurn());
expect(reported.filter((err) => String(err).includes('Spine:'))).toHaveLength(0);
});
it('stays quiet when ops outnumber receipts (a compaction can fold receipts away)', async () => {
const records = await recordSpineTurn();
const stripped = records.filter((record) => !isToolResultRecord(record, 'call_close'));
const reported = await restoreAndCaptureReports(stripped);
expect(reported.filter((err) => String(err).includes('Spine:'))).toHaveLength(0);
});
it('stays quiet on restore for legacy bare-accepted receipts', async () => {
const records = await recordSpineTurn();
const legacy = records.map((record) =>
isToolResultRecord(record, 'call_close') ? withToolResultText(record, 'accepted') : record,
);
const reported = await restoreAndCaptureReports(legacy);
expect(reported.filter((err) => String(err).includes('Spine:'))).toHaveLength(0);
});
it('reports a pending transition dropped when its step ends without evidence', async () => {
const { beforeStep, afterStep, reported, spine } = spineWithCapturedStepHooks();
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
expect(spine.acceptOpen('task A', 'call_open').accepted).toBe(true);
await hookOf(afterStep, 'spine')(afterCtx(new AbortController().signal), noopNext);
expect(reported).toHaveLength(1);
expect(String(reported[0])).toContain('call_open');
// The drop cleared the pending transition: the next step stays quiet.
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
expect(reported).toHaveLength(1);
});
it('stays quiet when the dropped transition belonged to an aborted step', async () => {
const { beforeStep, afterStep, reported, spine } = spineWithCapturedStepHooks();
const controller = new AbortController();
await hookOf(beforeStep, 'spine')(beforeCtx(controller.signal), noopNext);
spine.acceptOpen('task A', 'call_open');
controller.abort();
await hookOf(afterStep, 'spine')(afterCtx(controller.signal), noopNext);
expect(reported).toHaveLength(0);
});
it('attributes a leftover pending transition to its owning step across turns', async () => {
const { beforeStep, reported, spine } = spineWithCapturedStepHooks();
// Turn 1: the step owning the pending transition aborts before afterStep
// ever runs (turn-level cancel), leaving the pending behind.
const controller = new AbortController();
await hookOf(beforeStep, 'spine')(beforeCtx(controller.signal), noopNext);
spine.acceptOpen('task A', 'call_open');
controller.abort();
// Turn 2: dropping the leftover is routine — quiet.
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
expect(reported).toHaveLength(0);
// A leftover from a step that did NOT abort is anomalous — reported.
spine.acceptOpen('task B', 'call_open_2');
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
expect(reported).toHaveLength(1);
expect(String(reported[0])).toContain('call_open_2');
});
it('clamps the closing boundary at the span start', async () => {
// A truncation repair can restart an open span at the cut — past the end
// of the surviving history — so `assistantIndex - 1` would invert the
// span without the clamp.
const { beforeStep, afterStep, spine, ctx } = spineWithCapturedStepHooks(
execEnvServices({ hostFs: recordingHostFs().fs }),
);
ctx
.get(IAgentWireService)
.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 10 }));
ctx.context.append({
role: 'assistant',
content: [{ type: 'text', text: 'closing' }],
toolCalls: [{ type: 'function', id: 'call_close', name: 'spine_close', arguments: '{}' }],
});
ctx.context.append({
role: 'tool',
content: [{ type: 'text', text: ACCEPTED_OUTPUT }],
toolCalls: [],
toolCallId: 'call_close',
});
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
expect(spine.acceptClose('did A', 'call_close').accepted).toBe(true);
await hookOf(afterStep, 'spine')(afterCtx(new AbortController().signal), noopNext);
expect(readSpine(ctx).nodes['1.1.1']?.closedAt).toBe(10);
});
it('commits a leftover close at the next step start when its receipt landed', async () => {
// The abort path: afterStep never runs, so the close stays pending past its
// owning step. The next step's beforeStep must commit it (the receipt is
// already in context) so the tree catches up before the model sees the
// context — rather than dropping it and forking the tree from the receipt.
const { beforeStep, reported, spine, ctx } = spineWithCapturedStepHooks(
execEnvServices({ hostFs: recordingHostFs().fs }),
);
ctx
.get(IAgentWireService)
.dispatch(spineOpen({ id: '1.1.1', summary: 'task A', parentId: '1.1', openedAt: 0 }));
ctx.context.append({
role: 'assistant',
content: [{ type: 'text', text: 'work a' }],
toolCalls: [],
});
ctx.context.append({
role: 'assistant',
content: [{ type: 'text', text: 'work b' }],
toolCalls: [],
});
ctx.context.append({
role: 'assistant',
content: [{ type: 'text', text: 'closing' }],
toolCalls: [toolCallPart('call_close', 'spine_close', {})],
});
ctx.context.append({
role: 'tool',
content: [{ type: 'text', text: ACCEPTED_OUTPUT }],
toolCalls: [],
toolCallId: 'call_close',
});
// Owning step: accept the close, then abort before afterStep can commit it.
const controller = new AbortController();
await hookOf(beforeStep, 'spine')(beforeCtx(controller.signal), noopNext);
expect(spine.acceptClose('did A', 'call_close').accepted).toBe(true);
controller.abort();
// The next step begins: the leftover is committed (boundary = the carrier's
// predecessor), not dropped — and nothing is reported.
await hookOf(beforeStep, 'spine')(beforeCtx(new AbortController().signal), noopNext);
expect(readSpine(ctx).nodes['1.1.1']?.closedAt).toBe(1);
expect(reported).toHaveLength(0);
});
});
describe('spine control tool main-agent gating', () => {
@ -961,7 +797,13 @@ describe('spine control tool main-agent gating', () => {
});
});
// The live tree is derived from the message stream; read it through the service.
function readSpine(ctx: TestAgentContext) {
return ctx.get(IAgentSpineService).currentState();
}
// The legacy op reducers are exercised directly against the wire model.
function readOps(ctx: TestAgentContext) {
return ctx.get(IAgentWireService).getModel(SpineModel);
}
@ -1011,110 +853,6 @@ function textOf(message: { content?: readonly { type: string; text?: string }[]
);
}
async function recordSpineTurn(): Promise<readonly PersistedWireRecord[]> {
const persistence = new InMemoryWireRecordPersistence();
const ctx = testAgent({ persistence }, execEnvServices({ hostFs: recordingHostFs().fs }));
await configureLoop(ctx);
ctx.mockNextResponse(toolCallPart('call_open', 'spine_open', { summary: 'task A' }));
ctx.mockNextResponse(toolCallPart('call_close', 'spine_close', { memory: 'did A' }));
ctx.mockNextResponse({ type: 'text', text: 'finished' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'start' }] });
await ctx.untilTurnEnd();
return persistence.records;
}
async function restoreAndCaptureReports(
records: readonly PersistedWireRecord[],
): Promise<unknown[]> {
const reported: unknown[] = [];
setUnexpectedErrorHandler((err) => {
reported.push(err);
});
try {
const ctx = testAgent();
// Force the Eager spine service up so its onRestored audit is registered
// before the replay fires the restored handlers.
ctx.get(IAgentSpineService);
const wireRecord = ctx.get(IAgentWireRecordService);
await wireRecord.restore(records);
const restored = wireRecord.getRecords() as readonly PersistedRecord[];
await ctx.get(IAgentWireService).replay(...restored);
} finally {
resetUnexpectedErrorHandler();
}
return reported;
}
function isToolResultRecord(record: PersistedWireRecord, toolCallId: string): boolean {
const r = record as {
readonly type?: string;
readonly event?: { readonly type?: string; readonly toolCallId?: string };
};
return (
r.type === 'context.append_loop_event' &&
r.event?.type === 'tool.result' &&
r.event?.toolCallId === toolCallId
);
}
function withToolResultText(record: PersistedWireRecord, text: string): PersistedWireRecord {
const r = record as {
readonly event?: { readonly result?: { readonly output?: unknown } };
};
if (r.event?.result === undefined) return record;
return {
...record,
event: { ...r.event, result: { ...r.event.result, output: text } },
} as unknown as PersistedWireRecord;
}
type BeforeStepHook = (ctx: BeforeStepContext, next: () => Promise<void>) => Promise<void>;
type AfterStepHook = (ctx: AfterStepContext, next: () => Promise<void>) => Promise<void>;
const noopNext = async (): Promise<void> => {};
/**
* Stand up the spine service against a fake loop that captures the registered
* step hooks, so tests can drive beforeStep / afterStep by hand instead of
* racing a real turn.
*/
function spineWithCapturedStepHooks(...overrides: readonly TestAgentServiceOverride[]): {
readonly beforeStep: Map<string, BeforeStepHook>;
readonly afterStep: Map<string, AfterStepHook>;
readonly reported: unknown[];
readonly spine: IAgentSpineService;
readonly ctx: TestAgentContext;
} {
const beforeStep = new Map<string, BeforeStepHook>();
const afterStep = new Map<string, AfterStepHook>();
const fakeLoop = {
_serviceBrand: undefined,
run: async () => ({ type: 'completed' as const, steps: 0, truncated: false }),
hooks: {
onWillBeginStep: {
register: (name: string, fn: BeforeStepHook) => {
beforeStep.set(name, fn);
return Disposable.None;
},
},
onDidFinishStep: {
register: (name: string, fn: AfterStepHook) => {
afterStep.set(name, fn);
return Disposable.None;
},
},
onError: { register: () => Disposable.None },
},
registerLoopErrorHandler: () => Disposable.None,
} as unknown as IAgentLoopService;
const reported: unknown[] = [];
setUnexpectedErrorHandler((err) => {
reported.push(err);
});
const ctx = testAgent(agentService(IAgentLoopService, fakeLoop), ...overrides);
return { beforeStep, afterStep, reported, spine: ctx.get(IAgentSpineService), ctx };
}
/**
* Tool-call pairing invariant over a folded history: every tool result must
* appear after the assistant message carrying its call. Returns the ids of
@ -1137,16 +875,23 @@ function toolPairingGaps(messages: readonly ContextMessage[]): string[] {
return gaps;
}
function hookOf<THook>(hooks: Map<string, THook>, name: string): THook {
const hook = hooks.get(name);
if (hook === undefined) throw new Error(`hook '${name}' was not registered`);
return hook;
function assistantSpineCall(
id: string,
name: string,
args: Record<string, unknown>,
): ContextMessage {
return {
role: 'assistant',
content: [{ type: 'text', text: `calling ${name}` }],
toolCalls: [{ type: 'function', id, name, arguments: JSON.stringify(args) }],
};
}
function beforeCtx(signal: AbortSignal): BeforeStepContext {
return { turnId: 1, step: 1, signal };
}
function afterCtx(signal: AbortSignal): AfterStepContext {
return { turnId: 1, step: 1, signal } as unknown as AfterStepContext;
function spineReceipt(toolCallId: string): ContextMessage {
return {
role: 'tool',
content: [{ type: 'text', text: ACCEPTED_OUTPUT }],
toolCalls: [],
toolCallId,
};
}

View file

@ -62,6 +62,7 @@ export const LegacyStatusModel = defineDerivedModel<LegacyStatusState>(
export interface LegacyStatusSnapshot {
readonly usage?: UsageStatus;
readonly contextTokens: number;
readonly rawContextTokens: number;
readonly maxContextTokens: number;
readonly model: string;
}
@ -81,9 +82,13 @@ export function readLegacyStatus(agent: IAgentScopeHandle): LegacyStatusSnapshot
const contextSize = agent.accessor.get(IAgentContextSizeService);
const measured = agent.accessor.get(IAgentWireService).getModel(ContextSizeModel);
const contextTokens = Math.max(contextSize.get().size, measured.tokens);
// Unfolded twin of contextTokens, floored at the same reading so the wire
// invariant raw >= projected also holds in the transient window where the
// measured total outruns the live estimate (see above).
const rawContextTokens = Math.max(contextSize.rawSize(), contextTokens);
const maxContextTokens = profile.getModelCapabilities().max_context_tokens;
const model = profile.getModel();
return { usage, contextTokens, maxContextTokens, model };
return { usage, contextTokens, rawContextTokens, maxContextTokens, model };
}
/**

View file

@ -135,6 +135,8 @@ export const sessionStatusResponseSchema = z.object({
plan_mode: z.boolean(),
swarm_mode: z.boolean(),
context_tokens: z.number().int().nonnegative(),
// Unfolded-request cost (>= context_tokens); optional — older daemons omit it.
raw_context_tokens: z.number().int().nonnegative().optional(),
max_context_tokens: z.number().int().nonnegative(),
context_usage: z.number().min(0).max(1),
});