Skip to content

feat(kap-server): add the WS v2 realtime protocol stack (transcript-v3) - #3547

Open
liruifengv wants to merge 34 commits into
mainfrom
transcript-v3
Open

feat(kap-server): add the WS v2 realtime protocol stack (transcript-v3)#3547
liruifengv wants to merge 34 commits into
mainfrom
transcript-v3

Conversation

@liruifengv

@liruifengv liruifengv commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Related Issue

Internal protocol refactor — no linked issue. Client adoption: MoonshotAI/kimi-code-app#530. Continues the branch previously proposed as #3538 (fast-forward: same v2 stack plus the follow-up fixes below).

Problem

The v1 realtime channel carries two coexisting protocol families (legacy event frames and transcript frames), so one logical change has more than one source of truth. Clients cannot consume WS frames directly: every frame has to be applied through the transcript package before it becomes usable, and the frame granularity (off/turn/block/delta) is too low-level, forcing each client to rebuild message semantics on its own. Cold history is served in a different shape from the live stream, so refresh and reconnect recovery are ad hoc. Follow-up review against the real client exposed more gaps: approvals lost their display payloads, background task notices never surfaced, skill activations were a detached system marker, and several cold-history rebuilds silently dropped messages.

What changed

Adds a WS v2 protocol stack to kap-server, per packages/kap-server/docs/2026-09-04-v2-protocol-refactor-plan.md:

  • Schema single source (P0): src/protocol/v2/ — zod schemas for the full wire union: turn/step lifecycle, user/assistant/thinking/tool_call/system messages, task/plan/goal/sidechat/cron/attachment/compaction/undo frames, each carrying explicit status transitions. This is the only definition of the v2 wire shape; the code-app client re-exports it.
  • Normalized projection (P1): src/services/v2Projection/ projects engine loop events into wire messages — agentProjector for the live stream, coldHistory for a rebuild from persisted records. Parity-tested against 24 instance fixtures (test/v2Projection.test.ts).
  • REST history (P2): GET /sessions/{id}/history speaks the same wire shape as the live stream, paginates via before_turn, and reaches past compaction points.
  • WS v2 transport (P3): /api/v2/ws with the hello/subscribe/ack handshake, omit-based unsubscribe, recovery payloads for refresh/reconnect, backpressure, and per-subagent channels.
  • Full message coverage (P4): interactions (approval/question), plan, goal, sidechat, attachment, cron, compaction, undo, task, injection and subagent streams, plus global and session-index fan-out.
  • Engine integration (P5): the v2 stack runs against the real engine (test/v2Engine.e2e.test.ts); v1 stays untouched and serves current clients.

Follow-up fixes on top of the initial stack:

  • Steer chain: steered prompts are submitted in one step (the engine forwards a steer flag through prompt submission; the prompt route steers a pending handle within the same request), so the steered user message no longer renders twice.
  • Display passthrough: approval requests carry the engine's ToolInputDisplay, so an ExitPlanMode approval shows the plan review instead of an empty payload; cold history backfills a tool frame's display from its linked approval.
  • Background task notifications: task.notified projects to a user message with origin {kind: task} and a structured + raw notification payload — into the running turn when busy, or the task-origin delivery turn when idle. Cold history rebuilds both persistence forms (turn.prompt/turn.steer/context.append_message), and timed user messages survive step-boundary gaps.
  • Skill activations: projected as user messages with origin {kind: skill} instead of a detached system marker — only user-triggered (user-slash) activations surface, matching v1's hiding of model-tool activations. Bundled /skill prompts populate skill_activations; the turn's opening message is synthesized from turn.started when the prompt event is suppressed; cold history strips bundled skill blocks from the display text.
  • Projection hygiene: empty thinking parts are skipped; goal entries emit only on status transitions; before_turn paging crosses compaction points.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue (internal change — no issue).
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset (the v2 stack is an internal protocol change; the user-visible fixes each carry a changeset).
  • Ran gen-docs skill, or this PR needs no doc update (protocol decisions are recorded in the refactor plan doc in this branch).

Single source of truth for the v2 wire contract: 25-type ServerMessage
discriminated union, ClientMessage control frames, parseServerMessage,
entityId/entityKey. Exposed as @moonshot-ai/kap-server/protocol/v2;
code-app's app-core ws2/messages becomes a pure re-export.
Event2 -> ServerMessage projection core: per-agent timeline projector
(turn/step/text/tool/prompt domains), session.state composer, and a
script harness that deep-equals projected streams against the vendored
24-tab example fixtures (basic and tool tabs covered).
submitSteer enqueues through the same path as regular submissions, so a
steer-bound prompt was indistinguishable from a queue-bound one on the
event stream. The new optional steer flag lets projections hold the
queued emission and place the user message directly into the active
turn when prompt.steered arrives.
multi-tool, todo, queue-abort, tool-error and llm-retry streams now
deep-equal the example fixtures. The projector gains: tools.update_store
driven todo entities with projector-allocated td_* ids and the TodoWrite
back-link, prompt.aborted driven system interruption frames (m_* ids),
interrupted-step usage passthrough, error tool results without output,
retry-step text reset with placeholder re-announce, and progress cleanup
on tool completion. Turn usage now sums plain input_other/output only.
ProjectionEvent declares its payload fields explicitly to satisfy
noPropertyAccessFromIndexSignature.
…eams

Six more fixture tabs now deep-equal: approval (both branches), question,
background-task, compaction, undo and big-output. The projector gains a
generic interaction path (pending frames with tool_call approval_id
back-link, terminal frames preserving the original request), bus-driven
approval events (permission.approval.requested/resolved), compaction
system frames with summarized_through_turn derivation, undo frames from
context.undone, shell.output driven task output_tail refreshes, and a
turnId hint on prompt.submitted for engine-known turn clocks.
…treams

Five more fixture tabs deep-equal (19 total). plan mode enter/exit edges
from agent.status.updated with revision-tracked payloads, goal.updated
system frames and goal-continuation turns without user frames, sidechat
agents sharing the session turn clock, attachment_ids flowing from
prompt submissions into user and turn frames, and cron.fired normalized
through the prompt submission path. applyFacts now flushes in-flight
text before composing session.state, and modes becomes a per-frame
fact instead of sticky status state.
…live tabs

injection (steer, busy-cron, busy-task-notify) and subagent (foreground,
background, foreground-to-background, plus the on-demand subagent
channel) now deep-equal the fixtures, covering every engine-synthesizable
live tab. Busy cron injections take the system-steer path with pre-emptive
completion ordering, subagent lifecycle drives task entities with
child_agent_id and agent_refs on the parent tool call, and the subagent
channel projects the child agent's own stream under an r-prefixed turn
namespace.
recordRevision now derives a summary from the first markdown heading of
the snapshotted plan so wire consumers can label revisions without
fetching the blob.
buildColdHistory folds wire.jsonl into terminal v2 entities: turn groups
of step groups in wire order, protocol ids reproduced cold, in-flight
turns without a cover but with their completed steps, undo kept with a
marker, clear/compaction as fold floors, and keyset pagination over
before_turn/after_step. A 100k-line wire rebuild lands in ~240ms. The
route is served through defineRoute and shares readColdWireRecords with
the transcript service. Thirteen REST fixture sections now deep-equal
the builder output alongside the twenty-one live streams.
A session binder feeds engine events to the v2 projector and routes the
emitted messages to subscribed connections. WsConnectionV2 implements
hello on connect, subscribe/ack with ErrorCode acks, per-type omit, a
bounded outbound queue that closes slow consumers with
backpressure_overflow, protocol-layer heartbeats, and on-demand subagent
channels. Recovery is the same sequence as live: in-flight turn cover,
current step, accumulated streaming entities, state entities, and a fresh
session.state, synthesized from the projector's new recoveryEntities API.
recovery A/B, omit, backpressure-reconnect and the subagent channel now
deep-equal the fixtures end to end.
App-scope events now project to v2 global messages broadcast to every
v2 connection: session index frames (meta.updated with SessionInfo and
changed_fields, created/archived), workspace created/updated/deleted via
toWireWorkspace, sanitized config with changed_fields, config.warning,
and the thin model_catalog/plugin/capability notifications. Remaining
system subtypes (hook, skill, notice, clear, swarm enter/exit) are
projected, and the binder learns profile.bind as a model fact. The
session-changes and global tabs deep-equal, and the full kap-server
suite passes at 1374 tests.
Real-engine e2e (scripted provider, stub model) covers eight scenarios:
basic text, yolo Bash, manual approval with the approval_id back-link,
TodoWrite entities, abort with interruption, mid-turn disconnect
recovery, REST history after completion, and v1 protocol coexistence.
Fixes from the integration: engine step ordinals are 1-based so the
projection normalizes to the 0-based wire ids (with a phase clamp before
the first step starts), TodoList calls bridge to the TodoWrite wire
vocabulary at both the live and cold boundaries, tools.update_store is
now observable so live todo entities flow, and the wire manifest is
regenerated for the plan.revision summary addition.
The session settles to idle before prompt.aborted projects the
interruption marker; fixtures and the queue-abort script follow.
Durable interaction.request records and the interaction hub both carry
engine-shaped payloads (toolName/action, question items with object
options). Cold history and the live question path now pass them through
a shared toWireInteractionRequest/Response mapper (approval requests
recover their input from the linked tool call, question items get ids
and string options), instead of leaking engine shapes onto the wire and
tripping the client's contract guard.
PromptReservation.submit took no options, so a caller could not mark a
submission as a steer at enqueue time and had to steer in a separate
call afterwards. submit now accepts an optional steer flag and passes
it through to enqueue.
POST /sessions/{id}/prompts accepts steer: true. The reservation is
submitted with the steer flag and, when the handle lands pending, is
steered immediately within the same request; a PROMPT_NOT_FOUND race
against an already-launched prompt is tolerated. Clients no longer
issue a separate steer call that rendered the steered user message
twice.
- skip empty thinking parts in the agent loop (live stream, interrupted
  drain, cold history) so no empty thinking shells are emitted
- emit the steered prompt's user message when submission races with
  turn completion, assigning it to the new turn exactly once
- emit goal system entries only on goal status transitions, and mirror
  that in cold history (goal.create produces the active entry,
  goal.update/clear produce none)
- let before_turn history paging reach past the compaction floor so
  loading earlier messages keeps returning pages
- refresh the v2 example fixtures and add a steer-race projection case
Approval requests in the v2 stream dropped the engine's ToolInputDisplay,
so an ExitPlanMode approval reached clients as an empty {} payload instead
of the plan review. The approval request schema gains a display field, the
live projector and the cold-history wire mapper pass it through, and cold
history backfills a tool frame's display from its linked approval so plan
blocks survive a refresh.
A completed background task delivers its notice into the conversation
as a user message with origin {kind: task}; the v2 stream dropped it,
so clients showed nothing. The projector now maps task.notified into
a user message carrying a structured notification payload — into the
running turn when busy, or the predicted delivery turn when idle —
and cold history rebuilds the same shape from turn.prompt/turn.steer
records by parsing the notification XML. The TaskNotified engine
event carries the source agent id so subagent completions stay
distinguishable.
A task notice delivered into a running turn persists as a
context.append_message record, not turn.steer, so a refresh lost the
notification user message. Cold history now surfaces task-origin
appends (other origins stay hidden).
…tory

Steered and task-notification user messages materialize at step
boundaries, landing in the gap between one step's end and the next
step's begin, where the strict per-step window silently dropped
them. Timed users are now assigned to the first step sealed by their
time, falling back to the last visible step.
The wire notification keeps the small structured fields and gains the
raw XML envelope so clients render the card exactly like v1: clean
body, output file chip, output preview, and the raw payload
disclosure.
…ages

A skill activation is conversation content, not a system marker. The
system(skill) mark is gone; activations now surface as user messages
with origin {kind: skill}: model-tool and nested activations are
synthesized from skill.activated, user-slash injections arrive
through turn.steer, and prompts with bundled /skill mentions
populate skill_activations on the user message. Cold history mirrors
all three paths (skill turn origins are displayable, bundled
activations rebuild from turn.prompt).
prompt.submitted fires only for user-kind origins and carried no
origin, so bundled /skill prompts lost their skill_activations and
idle slash activations (skill_activation origin) produced a turn with
no opening user message. The event now carries the origin, and the
projector synthesizes the turn's opening user message from
turn.started when no prompt message exists (cron/task fallbacks are
deduped by the per-turn message count).
A model Skill tool call both fires skill.activated and steers the
skill instructions into the turn, so synthesizing from the event
duplicated the message. All activation paths are covered by the
submitted/steered/turn-start messages, so the event emits nothing.
Model-tool and nested-skill activations are engine internals (v1
hides them); only user-slash activations surface as skill-origin
user messages, in both the live projector and cold history.
A prompt with bundled /skill mentions persists the engineered skill
blocks as leading content parts; cold history rendered them raw into
the user bubble. The turn.prompt user text now slices them off like
the engine's stripBundledSkillBlocks.
@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d7f7f64

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d7f7f648ab

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +501 to +508
const wssV2 = registerWsV2({
binder: v2Binder,
registry: connectionRegistry,
serverId,
sessionSourceFor: (sessionId) => liveSessionSourceFor(core, sessionId),
globalFanout,
logger: { warn: (meta, msg) => logger.warn(meta, msg) },
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Gate the unreleased v2 protocol behind an experimental flag

The new /api/v2/ws server is instantiated and accepted unconditionally, so every production process exposes the protocol even though this change keeps v1 as the current client surface. Register the kap-server flag with a default of false and guard both route registration and upgrade handling so the incomplete protocol is opt-in until release.

AGENTS.md reference: AGENTS.md:L64-L68

Useful? React with 👍 / 👎.

Comment on lines +134 to +139
attach(source: V2SessionSource): SessionV2Binding {
let binding = this.bindings.get(source.sessionId);
if (!binding) {
binding = new SessionV2Binding(source, this.clock);
this.bindings.set(source.sessionId, binding);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Replace bindings when a session scope is recreated

After a live session is archived or closed and then restored in the same server process, attach returns the cached binding even though it is subscribed to the disposed session's event buses. SessionV2Binder.detach has no caller, so subsequent subscriptions to the restored session receive recovery from stale projector state and no new realtime events; compare the source generation or detach on session close before reusing this map entry.

Useful? React with 👍 / 👎.

Comment on lines +185 to +188
private watchAgent(agent: V2AgentSource): void {
const permission = agent.permissionMode?.();
if (permission !== undefined) this.projector.composer.apply({ permission });
this.disposables.push(agent.bus.subscribe((event) => this.onAgentEvent(agent.agentId, event)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Hydrate bindings before joining an active stream

When a REST caller starts a prompt before the first v2 subscriber connects, the binding is created after turn.started/turn.step.started; this forward-only bus subscription cannot replay those events, and no agent snapshot or pending-question synchronization is performed. Consequently recoveryFor is empty and current deltas are silently dropped until the next step because the projector has no currentStep; initialize the projector from live state/history before accepting the subscription.

Useful? React with 👍 / 👎.

Comment on lines +198 to +199
const patch = factsPatchForEvent(event);
if (patch) this.emitFacts(patch, event.time ?? this.clock());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restrict session-state facts to the main agent

When any subagent emits agent.activity.updated, agent.status.updated, goal.updated, or permission events, this path applies its patch to the single session composer and broadcasts it on the main session channel. A subagent turn can therefore replace the main agent's phase/model/permission in session.state; only main-agent facts should feed this composer, while the session work fields should continue to come from ISessionActivityView.

AGENTS.md reference: packages/kap-server/AGENTS.md:L33-L33

Useful? React with 👍 / 👎.

const systems = out as { type: string; subtype: string; system_id: string }[];
expect(systems.map((msg) => [msg.type, msg.subtype])).toEqual([
['system', 'hook'],
['system', 'skill'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove the stale skill system-frame expectation

This test always fails: AgentV2Projector.onSkillActivated deliberately emits nothing, so the constructed array contains five system messages while this assertion expects six and shifts every following entry. Since user-triggered skill activation is projected through the user-message/turn-steer path, remove the obsolete ['system', 'skill'] expectation and update the subsequent IDs.

Useful? React with 👍 / 👎.

@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@d7f7f64
npx https://pkg.pr.new/@moonshot-ai/kimi-code@d7f7f64

commit: d7f7f64

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant