Files
hapi/cli/src/agent/messageConverter.ts
T
Junmo KimandGitHub 0aebf39c78 fix(opencode): keep one stored message per reasoning stream (#1643)
* fix(acp): carry the live reasoning marker on the wire payload

ACP agents stream thoughts a token at a time, so the handler coalesces
them into a buffer and re-sends the whole buffer under a stable stream
id every 250ms. The converter dropped the marker that says a payload is
one of those throttled snapshots, leaving the hub unable to tell a
replaceable snapshot from the settled message that closes the stream.

Mirrors how the text variant already forwards streamSnapshot.

* fix(hub): keep one stored message per reasoning stream

OpenCode reasoning arrives as a series of growing snapshots sharing one
stream id, and every snapshot was persisted as its own message. A 26h
session reached 48,844 rows and 63MB, and because the web budgets a
fixed number of messages, its 400-message window covered barely three
minutes of conversation — scrolling up walked through duplicate
snapshots instead of history.

Retire a stream's earlier live snapshots once their replacement is
stored. Sweeping only after the insert matters: the two statements are
separate transactions, so clearing first would leave a window where a
crash takes the whole stream. Only rows marked live are eligible and the
replacement is spared, so a stream always keeps at least one row and the
settled message that closes it is never removed.

Live rendering is unchanged: the web still receives every snapshot and
already folds them by stream id.

* fix(web): spend the message window on conversation, not repeated snapshots

The window budgets raw messages, but a reasoning stream renders as a
single folded block no matter how many snapshots it arrived in. On
sessions recorded before the hub started retiring them, those snapshots
fill the window on their own: in one 26h session the newest 400 messages
covered 202 seconds, so scrolling up paged through duplicates instead of
history.

Collapse each stream to its newest snapshot before trimming. Rendering
is unchanged — the timeline already folds them by stream id — and rows
without a stream id are never touched.

* fix(ios,android): port reasoning-snapshot compaction to the native windows

The window logic in HapiProtocol and :core:protocol is a one-to-one port
of the web store, so collapsing superseded reasoning snapshots only on
the web left the native windows budgeting raw snapshot rows. The hub
stores one row per stream now, but a client that already holds the older
snapshots still spends its window on them.

Add the same stream-id reader and compaction to both ports, in the shape
each already uses for agent-run rows, and pin the behaviour with a
pagination fixture. Both fixture suites enumerate shared/fixtures/pagination
from disk, so the ports cannot drift from the web again without CI saying
so.
2026-08-20 08:55:21 +08:00

149 lines
5.8 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { INCLUSIVE_INPUT_TOKEN_USAGE_MARKER, type InclusiveInputTokenUsageMarker } from '@hapi/protocol/usage';
import type { AgentMessage, PlanItem } from './types';
import type { InlineMediaSource } from '@/modules/common/inlineMediaSource';
export type CodexMessage =
| { type: 'message'; message: string; id?: string; streamSnapshot?: boolean }
| { type: 'reasoning'; message: string; id: string; live?: boolean }
| {
type: 'token_count';
model: string | null;
usageSchema: InclusiveInputTokenUsageMarker['usageSchema'];
inputTokenSemantics: InclusiveInputTokenUsageMarker['inputTokenSemantics'];
info: {
total: {
inputTokens: number;
outputTokens: number;
totalTokens?: number;
thoughtTokens?: number;
cachedInputTokens?: number;
cacheWriteInputTokens?: number;
};
contextTokens?: number;
modelContextWindow?: number;
};
}
| {
type: 'tool-call';
name: string;
callId: string;
input: unknown;
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
nativeTitle?: string;
nativeKind?: string;
progress?: unknown;
}
| {
type: 'tool-call-result';
callId: string;
output: unknown;
is_error?: boolean;
}
| { type: 'plan'; entries: PlanItem[] }
| { type: 'error'; message: string }
| {
type: 'generated-image';
imageId: string;
fileName: string;
mimeType: string;
id: string;
source?: InlineMediaSource;
};
export function convertAgentMessage(message: AgentMessage, model?: string | null): CodexMessage | null {
switch (message.type) {
case 'text':
return {
type: 'message',
message: message.text,
...(message.id !== undefined ? { id: message.id } : {}),
...(message.streamSnapshot === true ? { streamSnapshot: true } : {})
};
case 'reasoning':
// AgentMessage uses `text` (consistent with the `text` variant);
// the wire-level CodexMessage uses `message` to match the
// existing reasoning format emitted by the Codex path.
//
// `live` marks a throttled snapshot of a still-growing buffer, so
// the hub can keep just the newest one per stream instead of
// storing every intermediate. The settled message that closes a
// stream carries no marker, which is what makes it the survivor.
return {
type: 'reasoning',
message: message.text,
id: message.id ?? randomUUID(),
...(message.live === true ? { live: true } : {})
};
case 'usage':
return {
type: 'token_count',
model: typeof model === 'string' && model.trim() ? model.trim() : null,
...INCLUSIVE_INPUT_TOKEN_USAGE_MARKER,
info: {
total: {
inputTokens: message.inputTokens
+ (message.cacheReadTokens ?? 0)
+ (message.cacheCreationTokens ?? 0),
outputTokens: message.outputTokens,
totalTokens: message.totalTokens,
thoughtTokens: message.thoughtTokens,
cachedInputTokens: message.cacheReadTokens,
...(message.cacheCreationTokens !== undefined
? { cacheWriteInputTokens: message.cacheCreationTokens }
: {})
},
contextTokens: message.contextTokens,
modelContextWindow: message.contextWindow
}
};
case 'tool_call':
return {
type: 'tool-call',
name: message.name,
callId: message.id,
input: message.input,
status: message.status,
...(message.title ? { nativeTitle: message.title } : {}),
...(message.kind ? { nativeKind: message.kind } : {}),
...(message.progress !== undefined ? { progress: message.progress } : {})
};
case 'tool_result':
return {
type: 'tool-call-result',
callId: message.id,
output: message.output,
is_error: message.status === 'failed'
};
case 'plan':
return {
type: 'plan',
entries: message.items
};
case 'generated_image':
return {
type: 'generated-image',
imageId: message.imageId,
fileName: message.fileName,
mimeType: message.mimeType,
id: randomUUID(),
source: message.source,
};
case 'error':
return { type: 'error', message: message.message };
case 'turn_complete':
return null;
default: {
// Unreachable while every AgentMessage variant is handled above —
// the `never` binding is what enforces that at compile time. The
// runtime return is deliberately `null` rather than the message
// itself: callers forward a non-null result straight into the chat
// stream, so echoing an unrecognized shape here would put a raw
// object on screen instead of failing closed.
const _exhaustive: never = message;
void _exhaustive;
return null;
}
}
}