From 1cbd845bb9f77bc7164ecf706cb3e628024d109a Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 17 Jul 2026 12:08:40 +0800 Subject: [PATCH] wip: checkpoint transcript-derived spine tree and web/TUI projection --- .changeset/spine-status-parent-goal.md | 5 + .changeset/spine-transcript-derived-tree.md | 5 + .../src/tui/utils/spine-projection.ts | 25 +- .../test/tui/utils/spine-projection.test.ts | 27 +- apps/kimi-web/src/App.vue | 1 + .../src/api/daemon/agentEventProjector.ts | 4 + apps/kimi-web/src/api/daemon/client.ts | 1 + apps/kimi-web/src/api/daemon/wire.ts | 1 + apps/kimi-web/src/api/types.ts | 5 + .../kimi-web/src/components/chat/ChatDock.vue | 35 +- .../kimi-web/src/components/chat/Composer.vue | 17 +- .../src/components/chat/ConversationPane.vue | 7 +- .../kimi-web/src/components/chat/TodoCard.vue | 56 +- .../src/components/ui/ContextRing.vue | 21 +- .../src/composables/spineTree.test.ts | 161 ++++ apps/kimi-web/src/composables/spineTree.ts | 154 ++++ .../src/composables/useKimiWebClient.ts | 15 + apps/kimi-web/src/i18n/locales/en/status.ts | 1 + apps/kimi-web/src/i18n/locales/en/tasks.ts | 1 + apps/kimi-web/src/i18n/locales/zh/status.ts | 1 + apps/kimi-web/src/i18n/locales/zh/tasks.ts | 1 + apps/kimi-web/src/types.ts | 16 + .../fullCompaction/fullCompactionService.ts | 21 +- .../agent-core-v2/src/agent/spine/index.ts | 1 + .../src/agent/spine/instructions.ts | 11 +- .../agent-core-v2/src/agent/spine/spine.ts | 10 +- .../src/agent/spine/spineDerive.ts | 236 +++++ .../src/agent/spine/spineFold.ts | 5 +- .../agent-core-v2/src/agent/spine/spineOps.ts | 27 +- .../src/agent/spine/spineService.ts | 713 ++++++-------- .../src/agent/spine/spineTree.ts | 27 +- .../src/agent/spine/tools/spine-close.ts | 2 +- .../src/agent/spine/tools/spine-next.ts | 4 +- .../src/agent/spine/tools/spine-open.ts | 2 +- .../app/sessionLegacy/sessionLegacyService.ts | 1 + .../fullCompaction/fullCompaction.test.ts | 63 +- .../app/sessionLegacy/sessionLegacy.test.ts | 2 +- .../agent-core-v2/test/spine/archive.test.ts | 50 +- .../test/spine/compaction.test.ts | 111 +-- .../agent-core-v2/test/spine/fold.test.ts | 870 ++++++++++++++---- .../agent-core-v2/test/spine/spine.test.ts | 453 ++------- .../src/services/legacyStatus/legacyStatus.ts | 7 +- packages/protocol/src/rest/session.ts | 2 + 43 files changed, 2008 insertions(+), 1170 deletions(-) create mode 100644 .changeset/spine-status-parent-goal.md create mode 100644 .changeset/spine-transcript-derived-tree.md create mode 100644 apps/kimi-web/src/composables/spineTree.test.ts create mode 100644 apps/kimi-web/src/composables/spineTree.ts create mode 100644 packages/agent-core-v2/src/agent/spine/spineDerive.ts diff --git a/.changeset/spine-status-parent-goal.md b/.changeset/spine-status-parent-goal.md new file mode 100644 index 000000000..e9a855701 --- /dev/null +++ b/.changeset/spine-status-parent-goal.md @@ -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. diff --git a/.changeset/spine-transcript-derived-tree.md b/.changeset/spine-transcript-derived-tree.md new file mode 100644 index 000000000..2c42f0c65 --- /dev/null +++ b/.changeset/spine-transcript-derived-tree.md @@ -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. diff --git a/apps/kimi-code/src/tui/utils/spine-projection.ts b/apps/kimi-code/src/tui/utils/spine-projection.ts index 2ca824a40..487cdef8f 100644 --- a/apps/kimi-code/src/tui/utils/spine-projection.ts +++ b/apps/kimi-code/src/tui/utils/spine-projection.ts @@ -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 = 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 | 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; diff --git a/apps/kimi-code/test/tui/utils/spine-projection.test.ts b/apps/kimi-code/test/tui/utils/spine-projection.test.ts index d074f24a4..42e0baf44 100644 --- a/apps/kimi-code/test/tui/utils/spine-projection.test.ts +++ b/apps/kimi-code/test/tui/utils/spine-projection.test.ts @@ -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) { 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); diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index d71852bc8..5674a55ec 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -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" diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index bcfefbb5d..17d4cf542 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -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({ diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 5ff57e741..30c883910 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -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, }; diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index d19cd45e4..e401d5faf 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -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; } diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 29b662c76..84b495bcd 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -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; } diff --git a/apps/kimi-web/src/components/chat/ChatDock.vue b/apps/kimi-web/src/components/chat/ChatDock.vue index ab84330a3..27a8a8a47 100644 --- a/apps/kimi-web/src/components/chat/ChatDock.vue +++ b/apps/kimi-web/src/components/chat/ChatDock.vue @@ -3,9 +3,9 @@