feat(coding): add renderer conversation store

This commit is contained in:
2026-08-23 22:04:55 +08:00
parent a0722574f3
commit 36626c88b5
14 changed files with 2703 additions and 1227 deletions

View File

@@ -1,435 +1 @@
export type ConversationThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high';
export interface ProductModelRef {
accountId: string;
modelId: string;
thinkingLevel: ConversationThinkingLevel;
}
export interface ConversationModelState {
model: ProductModelRef | null;
modelResolution: 'resolved' | 'required';
}
export interface PublicUsage {
inputTokens: number;
outputTokens: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
}
export type CodingRuntimeErrorCode =
| 'CODING_RUNTIME_START_FAILED'
| 'CODING_RUNTIME_READY_TIMEOUT'
| 'CODING_RUNTIME_PROTOCOL_ERROR'
| 'CODING_PROVIDER_AUTH_REQUIRED'
| 'CODING_MODEL_UNAVAILABLE'
| 'CODING_SESSION_UNREADABLE'
| 'CODING_STORAGE_WRITE_FAILED'
| 'CODING_REQUEST_UNCERTAIN'
| 'CODING_MIGRATION_MODEL_REQUIRED'
| 'CODING_CONVERSATION_NOT_FOUND';
export interface CodingRuntimePublicError {
code: CodingRuntimeErrorCode;
message: string;
recoverable: boolean;
}
export type ConversationRunStatus =
| 'idle'
| 'preparing'
| 'queued'
| 'running'
| 'retrying'
| 'compacting'
| 'aborting'
| 'error';
export type PromptMode = 'prompt' | 'steer' | 'follow-up';
export interface ConversationRunState {
status: ConversationRunStatus;
runId?: string;
mode?: PromptMode;
startedAt?: number;
settledAt?: number;
terminalReason?: 'completed' | 'aborted' | 'failed';
retry?: {
attempt: number;
delayMs: number;
};
error?: CodingRuntimePublicError;
}
export interface ConversationQueueItem {
id: string;
clientRequestId: string;
mode: 'steer' | 'follow-up';
text: string;
attachmentIds: string[];
}
export interface ConversationQueueState {
items: ConversationQueueItem[];
}
export interface ConversationContextState {
usedTokens: number;
contextWindow: number;
outputLimit?: number;
compaction: 'idle' | 'running';
lastCompactionId?: string;
recalculating?: boolean;
}
export interface ConversationInteractionOption {
id: string;
label: string;
description?: string;
}
export interface ConversationInteraction {
id: string;
conversationId: string;
runId: string;
kind: 'select' | 'confirm' | 'input' | 'editor';
title: string;
message?: string;
options?: ConversationInteractionOption[];
status: 'pending' | 'answered' | 'rejected' | 'cancelled';
}
export interface PublicWorkerState {
status: 'stopped' | 'starting' | 'ready' | 'recovering' | 'error';
generation: number;
error?: CodingRuntimePublicError;
}
export type ConversationContentBlock =
| {
kind: 'text';
id: string;
text: string;
status: 'streaming' | 'complete';
}
| {
kind: 'thinking';
id: string;
text: string;
status: 'streaming' | 'complete';
}
| {
kind: 'image';
id: string;
attachmentId: string;
mime: string;
};
export interface ConversationMessageNode {
kind: 'message';
id: string;
sourceEntryId?: string;
clientRequestId?: string;
role: 'user' | 'assistant';
status: 'optimistic' | 'streaming' | 'complete' | 'error' | 'aborted';
blocks: ConversationContentBlock[];
usage?: PublicUsage;
stopReason?: 'stop' | 'length' | 'tool-use' | 'error' | 'aborted';
}
export interface ChangedFileDetailsV1 {
schema: 'changed-file.v1';
paths: string[];
}
export interface TaskStateDetailsV1 {
schema: 'task-state.v1';
tasks: Array<{
id: string;
title: string;
status: 'pending' | 'running' | 'complete' | 'error';
}>;
}
export interface WriteLeaseDetailsV1 {
schema: 'write-lease.v1';
status: 'waiting' | 'held' | 'released';
}
export interface AgentBrowserDetailsV1 {
schema: 'agent-browser.v1';
action: 'open' | 'status' | 'close' | 'reset_profile' | 'navigate' | 'send_cdp' | 'read_events' | 'read_payload';
attachmentId?: string;
mime?: string;
}
export interface GameAssetsDetailsV1 {
schema: 'game-assets.v1';
invocationId: string;
candidateIds: string[];
status: 'pending' | 'resolved';
pendingAssetIds: string[];
approvedAssetIds: string[];
discardedAssetIds: string[];
}
export interface RuntimeContextDetailsV1 {
schema: 'runtime-context.v1';
skills: Array<{
id: string;
name: string;
description: string;
selected: boolean;
}>;
commands: Array<{
name: string;
title: string;
description: string;
source: 'makelore' | 'pi' | 'skill';
skillId?: string;
}>;
}
export interface SubagentDetailsV1 {
schema: 'subagent.v1';
dispatchId: string;
mode: 'single' | 'parallel' | 'chain';
tasks: Array<{
taskId: string;
agentId: string;
toolProfile: 'read-only' | 'coding';
status: 'queued' | 'running' | 'complete' | 'error' | 'aborted' | 'skipped';
summary?: string;
errorCode?: string;
usage?: PublicUsage;
}>;
}
export type KnownToolDetails =
| ChangedFileDetailsV1
| TaskStateDetailsV1
| WriteLeaseDetailsV1
| AgentBrowserDetailsV1
| GameAssetsDetailsV1
| RuntimeContextDetailsV1
| SubagentDetailsV1;
export interface ConversationToolNode {
kind: 'tool';
id: string;
toolCallId: string;
toolName: string;
title: string;
inputText: string;
status: 'declared' | 'waiting' | 'running' | 'complete' | 'error' | 'aborted';
output: ConversationContentBlock[];
details?: KnownToolDetails;
}
export interface ConversationCompactionNode {
kind: 'compaction';
id: string;
runId: string;
source: 'manual' | 'automatic';
status: 'running' | 'complete' | 'error';
willRetry: boolean;
summary?: string;
}
export interface ConversationBoundaryNode {
kind: 'boundary';
id: string;
runId: string;
boundary: 'turn-start' | 'turn-end' | 'retry';
attempt?: number;
delayMs?: number;
}
export interface ConversationSubagentNode {
kind: 'subagent';
id: string;
runId: string;
details: SubagentDetailsV1;
}
export interface ConversationNoticeNode {
kind: 'notice';
id: string;
code: string;
level: 'info' | 'warning' | 'error';
message: string;
}
export type ConversationNode =
| ConversationMessageNode
| ConversationToolNode
| ConversationCompactionNode
| ConversationBoundaryNode
| ConversationSubagentNode
| ConversationNoticeNode;
export interface ConversationSnapshot {
schemaVersion: 1;
conversation: {
id: string;
projectId: string;
agentId: string;
title: string;
model: ConversationModelState;
};
nodes: ConversationNode[];
run: ConversationRunState;
queue: ConversationQueueState;
context: ConversationContextState;
pendingInteractions: ConversationInteraction[];
worker: PublicWorkerState;
cursor: {
workerGeneration: number;
seq: number;
leafEntryId?: string;
};
}
export type ConversationPatch =
| { op: 'worker.state'; state: PublicWorkerState }
| { op: 'run.state'; run: ConversationRunState }
| { op: 'message.upsert'; node: ConversationMessageNode }
| { op: 'message.block-delta'; messageId: string; blockId: string; delta: string }
| { op: 'tool.upsert'; node: ConversationToolNode }
| { op: 'compaction.upsert'; node: ConversationCompactionNode }
| { op: 'boundary.upsert'; node: ConversationBoundaryNode }
| { op: 'subagent.upsert'; node: ConversationSubagentNode }
| { op: 'queue.replace'; queue: ConversationQueueState }
| { op: 'interaction.upsert'; interaction: ConversationInteraction }
| { op: 'interaction.remove'; interactionId: string }
| { op: 'context.replace'; context: ConversationContextState }
| { op: 'snapshot.invalidated'; reason: string };
export interface ConversationPatchEnvelope {
conversationId: string;
workerGeneration: number;
runId?: string;
seq: number;
at: number;
patch: ConversationPatch;
}
export interface PrepareConversationInput {
conversationId: string;
projectId: string;
agentId: string;
title: string;
model: ConversationModelState;
}
export interface ConversationRuntimeState {
conversationId: string;
status: PublicWorkerState['status'];
workerGeneration: number;
error?: CodingRuntimePublicError;
}
export interface PromptConversationInput {
clientRequestId: string;
conversationId: string;
mode: PromptMode;
text: string;
attachments: Array<{ attachmentId: string }>;
}
export interface QueueMessageInput {
clientRequestId: string;
conversationId: string;
text: string;
attachments: Array<{ attachmentId: string }>;
}
export interface PromptAcceptance {
accepted: true;
conversationId: string;
clientRequestId: string;
runId: string;
mode: PromptMode;
queuePosition?: number;
}
export interface QueueAcceptance {
accepted: true;
conversationId: string;
clientRequestId: string;
mode: 'steer' | 'follow-up';
queuePosition: number;
}
export interface SetConversationModelInput {
conversationId: string;
accountId: string;
modelId: string;
}
export interface SetThinkingLevelInput {
conversationId: string;
thinkingLevel: ConversationThinkingLevel;
}
export interface ForkConversationInput {
sourceConversationId: string;
sourceEntryId?: string;
conversation: PrepareConversationInput;
}
export interface ForkResult {
conversationId: string;
snapshot: ConversationSnapshot;
}
export interface CodingRuntimeCommand {
name: string;
description?: string;
}
export type ConversationInteractionResponse =
| { interactionId: string; cancelled: true }
| { interactionId: string; optionId: string }
| { interactionId: string; confirmed: boolean }
| { interactionId: string; value: string };
export interface CodingRuntimeDiagnostics {
revision: {
provider: number;
resources: number;
};
workers: Array<{
conversationId: string;
generation: number;
state: 'spawning' | 'ready' | 'queued' | 'running' | 'idle' | 'crashed';
stage: 'starting' | 'idle' | 'queued' | 'running' | 'failed';
}>;
}
export interface CodingConversationRuntime {
prepare(input: PrepareConversationInput): Promise<ConversationRuntimeState>;
getSnapshot(conversationId: string): Promise<ConversationSnapshot>;
prompt(input: PromptConversationInput): Promise<PromptAcceptance>;
steer(input: QueueMessageInput): Promise<QueueAcceptance>;
followUp(input: QueueMessageInput): Promise<QueueAcceptance>;
abort(conversationId: string): Promise<void>;
validateModel(model: ProductModelRef): Promise<ProductModelRef>;
setModel(input: SetConversationModelInput): Promise<ConversationModelState>;
setThinking(input: SetThinkingLevelInput): Promise<ConversationModelState>;
compact(conversationId: string): Promise<void>;
fork(input: ForkConversationInput): Promise<ForkResult>;
recover(conversationId: string): Promise<ConversationRuntimeState>;
dispose(conversationId: string): Promise<void>;
listCommands(conversationId: string): Promise<CodingRuntimeCommand[]>;
listInteractions(conversationId?: string): Promise<ConversationInteraction[]>;
respondInteraction(
conversationId: string,
response: ConversationInteractionResponse,
): Promise<void>;
getDiagnostics(): CodingRuntimeDiagnostics;
markProviderStale(): void;
markResourcesStale(): void;
subscribe(listener: (patch: ConversationPatchEnvelope) => void): () => void;
}
export * from '../../shared/coding-conversation-contracts';

View File

@@ -1,554 +1 @@
import type {
CodingRuntimePublicError,
ConversationContentBlock,
ConversationInteraction,
ConversationMessageNode,
ConversationNode,
ConversationPatch,
ConversationPatchEnvelope,
ConversationQueueState,
ConversationRunState,
ConversationSnapshot,
ConversationSubagentNode,
ConversationToolNode,
KnownToolDetails,
PublicWorkerState,
} from './contracts';
import { productToolDetails } from './product-tool-protocol';
import { projectSubagentDetailsV1 } from './subagent-protocol';
export type ConversationInvalidationCode =
| 'snapshot-required'
| 'unsupported-schema'
| 'malformed-snapshot'
| 'malformed-envelope'
| 'unknown-patch'
| 'generation-gap'
| 'sequence-gap'
| 'patch-target-missing'
| 'explicit-invalidation';
export interface ConversationInvalidation {
code: ConversationInvalidationCode;
reason: string;
expectedGeneration?: number;
actualGeneration?: number;
expectedSeq?: number;
actualSeq?: number;
}
export interface ConversationReducerState {
snapshot: ConversationSnapshot | null;
invalidation: ConversationInvalidation | null;
}
const RUN_STATUSES = new Set([
'idle',
'preparing',
'queued',
'running',
'retrying',
'compacting',
'aborting',
'error',
]);
const WORKER_STATUSES = new Set(['stopped', 'starting', 'ready', 'recovering', 'error']);
const ERROR_CODES = new Set([
'CODING_RUNTIME_START_FAILED',
'CODING_RUNTIME_READY_TIMEOUT',
'CODING_RUNTIME_PROTOCOL_ERROR',
'CODING_PROVIDER_AUTH_REQUIRED',
'CODING_MODEL_UNAVAILABLE',
'CODING_SESSION_UNREADABLE',
'CODING_STORAGE_WRITE_FAILED',
'CODING_REQUEST_UNCERTAIN',
'CODING_MIGRATION_MODEL_REQUIRED',
'CODING_CONVERSATION_NOT_FOUND',
]);
const PATCH_OPS = new Set([
'worker.state',
'run.state',
'message.upsert',
'message.block-delta',
'tool.upsert',
'compaction.upsert',
'boundary.upsert',
'subagent.upsert',
'queue.replace',
'interaction.upsert',
'interaction.remove',
'context.replace',
'snapshot.invalidated',
]);
function clone<T>(value: T): T {
return structuredClone(value);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
function isNonNegativeInteger(value: unknown): value is number {
return Number.isInteger(value) && Number(value) >= 0;
}
function isOptionalNumber(value: unknown): boolean {
return value === undefined || typeof value === 'number';
}
function isPublicError(value: unknown): value is CodingRuntimePublicError {
const record = asRecord(value);
return record !== null
&& ERROR_CODES.has(String(record.code))
&& isNonEmptyString(record.message)
&& typeof record.recoverable === 'boolean';
}
function isModelState(value: unknown): boolean {
const record = asRecord(value);
if (!record || !['resolved', 'required'].includes(String(record.modelResolution))) return false;
if (record.model === null) return record.modelResolution === 'required';
const model = asRecord(record.model);
return model !== null
&& isNonEmptyString(model.accountId)
&& isNonEmptyString(model.modelId)
&& ['off', 'minimal', 'low', 'medium', 'high'].includes(String(model.thinkingLevel))
&& record.modelResolution === 'resolved';
}
function isUsage(value: unknown): boolean {
const record = asRecord(value);
return record !== null
&& typeof record.inputTokens === 'number'
&& typeof record.outputTokens === 'number'
&& isOptionalNumber(record.cacheReadTokens)
&& isOptionalNumber(record.cacheWriteTokens);
}
function isContentBlock(value: unknown): value is ConversationContentBlock {
const record = asRecord(value);
if (!record || !isNonEmptyString(record.id) || !isNonEmptyString(record.kind)) return false;
if (record.kind === 'image') {
return isNonEmptyString(record.attachmentId) && isNonEmptyString(record.mime);
}
return (record.kind === 'text' || record.kind === 'thinking')
&& typeof record.text === 'string'
&& (record.status === 'streaming' || record.status === 'complete');
}
function isKnownToolDetails(value: unknown): value is KnownToolDetails {
const record = asRecord(value);
if (!record || !isNonEmptyString(record.schema)) return false;
if (productToolDetails(record)) return true;
if (record.schema === 'write-lease.v1') {
return record.status === 'waiting' || record.status === 'held' || record.status === 'released';
}
if (record.schema === 'subagent.v1') {
return projectSubagentDetailsV1(record) !== undefined;
}
return false;
}
function isMessageNode(value: unknown): value is ConversationMessageNode {
const record = asRecord(value);
return record !== null
&& record.kind === 'message'
&& isNonEmptyString(record.id)
&& (record.role === 'user' || record.role === 'assistant')
&& ['optimistic', 'streaming', 'complete', 'error', 'aborted'].includes(String(record.status))
&& Array.isArray(record.blocks)
&& record.blocks.every(isContentBlock)
&& (record.usage === undefined || isUsage(record.usage));
}
function isToolNode(value: unknown): value is ConversationToolNode {
const record = asRecord(value);
return record !== null
&& record.kind === 'tool'
&& isNonEmptyString(record.id)
&& isNonEmptyString(record.toolCallId)
&& isNonEmptyString(record.toolName)
&& typeof record.title === 'string'
&& typeof record.inputText === 'string'
&& ['declared', 'waiting', 'running', 'complete', 'error', 'aborted']
.includes(String(record.status))
&& Array.isArray(record.output)
&& record.output.every(isContentBlock)
&& (record.details === undefined || isKnownToolDetails(record.details));
}
function isSubagentNode(value: unknown): value is ConversationSubagentNode {
const record = asRecord(value);
return record !== null
&& record.kind === 'subagent'
&& isNonEmptyString(record.id)
&& isNonEmptyString(record.runId)
&& isKnownToolDetails(record.details)
&& record.details.schema === 'subagent.v1';
}
function isConversationNode(value: unknown): value is ConversationNode {
const record = asRecord(value);
if (!record || !isNonEmptyString(record.kind) || !isNonEmptyString(record.id)) return false;
if (record.kind === 'message') return isMessageNode(record);
if (record.kind === 'tool') return isToolNode(record);
if (record.kind === 'subagent') return isSubagentNode(record);
if (record.kind === 'compaction') {
return isNonEmptyString(record.runId)
&& ['manual', 'automatic'].includes(String(record.source))
&& ['running', 'complete', 'error'].includes(String(record.status))
&& typeof record.willRetry === 'boolean';
}
if (record.kind === 'boundary') {
return isNonEmptyString(record.runId)
&& ['turn-start', 'turn-end', 'retry'].includes(String(record.boundary));
}
if (record.kind === 'notice') {
return isNonEmptyString(record.code)
&& ['info', 'warning', 'error'].includes(String(record.level))
&& typeof record.message === 'string';
}
return false;
}
function isInteraction(value: unknown): value is ConversationInteraction {
const record = asRecord(value);
return record !== null
&& isNonEmptyString(record.id)
&& isNonEmptyString(record.conversationId)
&& isNonEmptyString(record.runId)
&& ['select', 'confirm', 'input', 'editor'].includes(String(record.kind))
&& typeof record.title === 'string'
&& ['pending', 'answered', 'rejected', 'cancelled'].includes(String(record.status));
}
function isRunState(value: unknown): value is ConversationRunState {
const record = asRecord(value);
return record !== null
&& RUN_STATUSES.has(String(record.status))
&& (record.error === undefined || isPublicError(record.error));
}
function isQueueState(value: unknown): value is ConversationQueueState {
const record = asRecord(value);
return record !== null && Array.isArray(record.items) && record.items.every((item) => {
const queueItem = asRecord(item);
return queueItem !== null
&& isNonEmptyString(queueItem.id)
&& isNonEmptyString(queueItem.clientRequestId)
&& ['steer', 'follow-up'].includes(String(queueItem.mode))
&& typeof queueItem.text === 'string'
&& Array.isArray(queueItem.attachmentIds)
&& queueItem.attachmentIds.every(isNonEmptyString);
});
}
function isWorkerState(value: unknown): value is PublicWorkerState {
const record = asRecord(value);
return record !== null
&& WORKER_STATUSES.has(String(record.status))
&& isNonNegativeInteger(record.generation)
&& (record.error === undefined || isPublicError(record.error));
}
export function isConversationSnapshot(value: unknown): value is ConversationSnapshot {
const record = asRecord(value);
const conversation = asRecord(record?.conversation);
const cursor = asRecord(record?.cursor);
const context = asRecord(record?.context);
const worker = record?.worker;
return record !== null
&& record.schemaVersion === 1
&& conversation !== null
&& isNonEmptyString(conversation.id)
&& isNonEmptyString(conversation.projectId)
&& isNonEmptyString(conversation.agentId)
&& typeof conversation.title === 'string'
&& isModelState(conversation.model)
&& Array.isArray(record.nodes)
&& record.nodes.every(isConversationNode)
&& isRunState(record.run)
&& isQueueState(record.queue)
&& context !== null
&& typeof context.usedTokens === 'number'
&& typeof context.contextWindow === 'number'
&& ['idle', 'running'].includes(String(context.compaction))
&& (context.recalculating === undefined || typeof context.recalculating === 'boolean')
&& Array.isArray(record.pendingInteractions)
&& record.pendingInteractions.every(isInteraction)
&& isWorkerState(worker)
&& cursor !== null
&& isNonNegativeInteger(cursor.workerGeneration)
&& isNonNegativeInteger(cursor.seq)
&& asRecord(worker)?.generation === cursor.workerGeneration;
}
function isPatch(value: unknown): value is ConversationPatch {
const record = asRecord(value);
if (!record || !PATCH_OPS.has(String(record.op))) return false;
switch (record.op) {
case 'worker.state':
return isWorkerState(record.state);
case 'run.state':
return isRunState(record.run);
case 'message.upsert':
return isMessageNode(record.node);
case 'message.block-delta':
return isNonEmptyString(record.messageId)
&& isNonEmptyString(record.blockId)
&& typeof record.delta === 'string';
case 'tool.upsert':
return isToolNode(record.node);
case 'compaction.upsert':
return isConversationNode(record.node) && record.node.kind === 'compaction';
case 'boundary.upsert':
return isConversationNode(record.node) && record.node.kind === 'boundary';
case 'subagent.upsert':
return isSubagentNode(record.node);
case 'queue.replace':
return isQueueState(record.queue);
case 'interaction.upsert':
return isInteraction(record.interaction);
case 'interaction.remove':
return isNonEmptyString(record.interactionId);
case 'context.replace': {
const context = asRecord(record.context);
return context !== null
&& typeof context.usedTokens === 'number'
&& typeof context.contextWindow === 'number'
&& ['idle', 'running'].includes(String(context.compaction))
&& (context.recalculating === undefined || typeof context.recalculating === 'boolean');
}
case 'snapshot.invalidated':
return isNonEmptyString(record.reason);
default:
return false;
}
}
function parseEnvelopeShell(value: unknown): Omit<ConversationPatchEnvelope, 'patch'> & { patch: unknown } | null {
const record = asRecord(value);
if (!record
|| !isNonEmptyString(record.conversationId)
|| !isNonNegativeInteger(record.workerGeneration)
|| !isNonNegativeInteger(record.seq)
|| typeof record.at !== 'number') {
return null;
}
return record as unknown as Omit<ConversationPatchEnvelope, 'patch'> & { patch: unknown };
}
function invalidate(
state: ConversationReducerState,
invalidation: ConversationInvalidation,
): ConversationReducerState {
return { snapshot: state.snapshot, invalidation };
}
export function createConversationReducerState(snapshot?: unknown): ConversationReducerState {
if (snapshot === undefined) return { snapshot: null, invalidation: null };
return replaceConversationSnapshot({ snapshot: null, invalidation: null }, snapshot);
}
export function replaceConversationSnapshot(
state: ConversationReducerState,
snapshot: unknown,
): ConversationReducerState {
const record = asRecord(snapshot);
if (record?.schemaVersion !== 1) {
return invalidate(state, {
code: 'unsupported-schema',
reason: 'Conversation snapshot schemaVersion must equal 1',
});
}
if (!isConversationSnapshot(snapshot)) {
return invalidate(state, {
code: 'malformed-snapshot',
reason: 'Conversation snapshot does not match the product contract',
});
}
return { snapshot: clone(snapshot), invalidation: null };
}
function upsertMessage(nodes: ConversationNode[], incoming: ConversationMessageNode): ConversationNode[] {
const index = nodes.findIndex((node) => node.kind === 'message' && (
node.id === incoming.id
|| (incoming.sourceEntryId !== undefined && node.sourceEntryId === incoming.sourceEntryId)
|| (incoming.clientRequestId !== undefined && node.clientRequestId === incoming.clientRequestId)
));
if (index < 0) return [...nodes, clone(incoming)];
const existing = nodes[index] as ConversationMessageNode;
const next = [...nodes];
next[index] = { ...clone(incoming), id: existing.id };
return next;
}
function upsertTool(nodes: ConversationNode[], incoming: ConversationToolNode): ConversationNode[] {
const index = nodes.findIndex((node) => node.kind === 'tool' && (
node.id === incoming.id || node.toolCallId === incoming.toolCallId
));
if (index < 0) return [...nodes, clone(incoming)];
const existing = nodes[index] as ConversationToolNode;
const next = [...nodes];
next[index] = { ...clone(incoming), id: existing.id };
return next;
}
function upsertById(nodes: ConversationNode[], incoming: ConversationNode): ConversationNode[] {
const index = nodes.findIndex((node) => node.kind === incoming.kind && node.id === incoming.id);
if (index < 0) return [...nodes, clone(incoming)];
const next = [...nodes];
next[index] = clone(incoming);
return next;
}
function applyPatch(
snapshot: ConversationSnapshot,
patch: ConversationPatch,
): { snapshot: ConversationSnapshot } | { missingTarget: string } {
switch (patch.op) {
case 'worker.state':
return { snapshot: { ...snapshot, worker: clone(patch.state) } };
case 'run.state':
return { snapshot: { ...snapshot, run: clone(patch.run) } };
case 'message.upsert':
return { snapshot: { ...snapshot, nodes: upsertMessage(snapshot.nodes, patch.node) } };
case 'message.block-delta': {
const messageIndex = snapshot.nodes.findIndex(
(node) => node.kind === 'message' && node.id === patch.messageId,
);
if (messageIndex < 0) return { missingTarget: `message:${patch.messageId}` };
const message = snapshot.nodes[messageIndex] as ConversationMessageNode;
const blockIndex = message.blocks.findIndex((block) => block.id === patch.blockId);
if (blockIndex < 0) return { missingTarget: `block:${patch.blockId}` };
const block = message.blocks[blockIndex];
if (block.kind === 'image') return { missingTarget: `text-block:${patch.blockId}` };
const blocks = [...message.blocks];
blocks[blockIndex] = { ...block, text: `${block.text}${patch.delta}` };
const nodes = [...snapshot.nodes];
nodes[messageIndex] = { ...message, blocks };
return { snapshot: { ...snapshot, nodes } };
}
case 'tool.upsert':
return { snapshot: { ...snapshot, nodes: upsertTool(snapshot.nodes, patch.node) } };
case 'compaction.upsert':
case 'boundary.upsert':
case 'subagent.upsert':
return { snapshot: { ...snapshot, nodes: upsertById(snapshot.nodes, patch.node) } };
case 'queue.replace':
return { snapshot: { ...snapshot, queue: clone(patch.queue) } };
case 'interaction.upsert': {
const index = snapshot.pendingInteractions.findIndex((item) => item.id === patch.interaction.id);
const pendingInteractions = [...snapshot.pendingInteractions];
if (index < 0) pendingInteractions.push(clone(patch.interaction));
else pendingInteractions[index] = clone(patch.interaction);
return { snapshot: { ...snapshot, pendingInteractions } };
}
case 'interaction.remove':
return {
snapshot: {
...snapshot,
pendingInteractions: snapshot.pendingInteractions.filter(
(item) => item.id !== patch.interactionId,
),
},
};
case 'context.replace':
return { snapshot: { ...snapshot, context: clone(patch.context) } };
case 'snapshot.invalidated':
return { missingTarget: patch.reason };
default:
return { missingTarget: 'unknown patch' };
}
}
export function reduceConversationPatch(
state: ConversationReducerState,
value: unknown,
): ConversationReducerState {
if (state.invalidation) return state;
if (!state.snapshot) {
return invalidate(state, {
code: 'snapshot-required',
reason: 'A Conversation snapshot is required before live patches',
});
}
const envelope = parseEnvelopeShell(value);
if (!envelope) {
return invalidate(state, {
code: 'malformed-envelope',
reason: 'Conversation patch envelope is malformed',
});
}
if (envelope.conversationId !== state.snapshot.conversation.id) return state;
const currentGeneration = state.snapshot.cursor.workerGeneration;
if (envelope.workerGeneration < currentGeneration) return state;
if (envelope.workerGeneration > currentGeneration) {
return invalidate(state, {
code: 'generation-gap',
reason: 'A newer worker generation requires a fresh snapshot',
expectedGeneration: currentGeneration,
actualGeneration: envelope.workerGeneration,
});
}
const currentSeq = state.snapshot.cursor.seq;
if (envelope.seq <= currentSeq) return state;
if (envelope.seq !== currentSeq + 1) {
return invalidate(state, {
code: 'sequence-gap',
reason: 'Conversation patch sequence has a gap',
expectedSeq: currentSeq + 1,
actualSeq: envelope.seq,
});
}
const patchRecord = asRecord(envelope.patch);
if (!patchRecord || !PATCH_OPS.has(String(patchRecord.op))) {
return invalidate(state, {
code: 'unknown-patch',
reason: 'Conversation patch operation is not supported',
});
}
if (!isPatch(envelope.patch)) {
return invalidate(state, {
code: 'unknown-patch',
reason: 'Conversation patch payload does not match its operation',
});
}
if (envelope.patch.op === 'snapshot.invalidated') {
return invalidate(state, {
code: 'explicit-invalidation',
reason: envelope.patch.reason,
});
}
const applied = applyPatch(state.snapshot, envelope.patch);
if ('missingTarget' in applied) {
return invalidate(state, {
code: 'patch-target-missing',
reason: `Conversation patch target is missing: ${applied.missingTarget}`,
});
}
return {
snapshot: {
...applied.snapshot,
cursor: {
...applied.snapshot.cursor,
workerGeneration: envelope.workerGeneration,
seq: envelope.seq,
},
},
invalidation: null,
};
}
export * from '../../shared/coding-conversation-reducer';

View File

@@ -1,156 +1 @@
import type {
AgentBrowserDetailsV1,
ChangedFileDetailsV1,
GameAssetsDetailsV1,
KnownToolDetails,
RuntimeContextDetailsV1,
TaskStateDetailsV1,
} from './contracts';
const PRODUCT_TOOL_NAMES = new Set([
'agent_browser',
'game_asset_browser',
'game_asset_review',
'task_state',
'changed_file',
'runtime_context',
]);
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown, max = 4096): string | null {
return typeof value === 'string' && value.trim() && value.length <= max ? value : null;
}
function strings(value: unknown, maxItems = 200): string[] | null {
if (!Array.isArray(value) || value.length > maxItems) return null;
const result: string[] = [];
for (const item of value) {
const normalized = text(item);
if (!normalized) return null;
result.push(normalized);
}
return result;
}
function relativePaths(value: unknown): string[] | null {
const paths = strings(value);
if (!paths) return null;
const result: string[] = [];
for (const candidate of paths) {
const normalized = candidate.replaceAll('\\', '/').replace(/^\.\//, '');
if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)
|| normalized.split('/').some((segment) => segment === '..')) return null;
result.push(normalized);
}
return result;
}
function browserDetails(value: Record<string, unknown>): AgentBrowserDetailsV1 | null {
const actions = new Set([
'open', 'status', 'close', 'reset_profile', 'navigate', 'send_cdp', 'read_events', 'read_payload',
]);
if (!actions.has(String(value.action))) return null;
const attachmentId = value.attachmentId === undefined ? undefined : text(value.attachmentId, 128);
const mime = value.mime === undefined ? undefined : text(value.mime, 128);
if ((value.attachmentId !== undefined && !attachmentId) || (value.mime !== undefined && !mime)) return null;
if (Boolean(attachmentId) !== Boolean(mime) || (attachmentId && value.action !== 'send_cdp')) return null;
return {
schema: 'agent-browser.v1',
action: value.action as AgentBrowserDetailsV1['action'],
...(attachmentId ? { attachmentId } : {}),
...(mime ? { mime } : {}),
};
}
function gameAssetDetails(value: Record<string, unknown>): GameAssetsDetailsV1 | null {
const invocationId = text(value.invocationId, 200);
const candidateIds = strings(value.candidateIds);
const pendingAssetIds = strings(value.pendingAssetIds);
const approvedAssetIds = strings(value.approvedAssetIds);
const discardedAssetIds = strings(value.discardedAssetIds);
if (!invocationId || !candidateIds || !pendingAssetIds || !approvedAssetIds || !discardedAssetIds) return null;
if (value.status !== 'pending' && value.status !== 'resolved') return null;
return {
schema: 'game-assets.v1',
invocationId,
candidateIds,
status: value.status,
pendingAssetIds,
approvedAssetIds,
discardedAssetIds,
};
}
function taskDetails(value: Record<string, unknown>): TaskStateDetailsV1 | null {
if (!Array.isArray(value.tasks) || value.tasks.length === 0 || value.tasks.length > 100) return null;
const tasks: TaskStateDetailsV1['tasks'] = [];
for (const candidate of value.tasks) {
const item = record(candidate);
const id = text(item?.id, 128);
const title = text(item?.title, 500);
if (!item || !id || !title || !['pending', 'running', 'complete', 'error'].includes(String(item.status))) {
return null;
}
tasks.push({ id, title, status: item.status as TaskStateDetailsV1['tasks'][number]['status'] });
}
return { schema: 'task-state.v1', tasks };
}
function runtimeContextDetails(value: Record<string, unknown>): RuntimeContextDetailsV1 | null {
if (!Array.isArray(value.skills) || value.skills.length > 100
|| !Array.isArray(value.commands) || value.commands.length > 200) return null;
const skills: RuntimeContextDetailsV1['skills'] = [];
const commands: RuntimeContextDetailsV1['commands'] = [];
for (const candidate of value.skills) {
const item = record(candidate);
const id = text(item?.id, 128);
const name = text(item?.name, 200);
if (!item || !id || !name || typeof item.description !== 'string' || item.description.length > 2000
|| typeof item.selected !== 'boolean') return null;
skills.push({ id, name, description: item.description, selected: item.selected });
}
for (const candidate of value.commands) {
const item = record(candidate);
const name = text(item?.name, 128);
const title = text(item?.title, 200);
if (!item || !name || !title || typeof item.description !== 'string' || item.description.length > 2000
|| !['makelore', 'pi', 'skill'].includes(String(item.source))) return null;
const skillId = item.skillId === undefined ? undefined : text(item.skillId, 128);
if (item.skillId !== undefined && !skillId) return null;
commands.push({
name,
title,
description: item.description,
source: item.source as RuntimeContextDetailsV1['commands'][number]['source'],
...(skillId ? { skillId } : {}),
});
}
return { schema: 'runtime-context.v1', skills, commands };
}
export function productToolDetails(value: unknown): Exclude<KnownToolDetails, { schema: 'subagent.v1' | 'write-lease.v1' }> | null {
const details = record(value);
if (!details) return null;
if (details.schema === 'changed-file.v1') {
const paths = relativePaths(details.paths);
return paths ? { schema: 'changed-file.v1', paths } satisfies ChangedFileDetailsV1 : null;
}
if (details.schema === 'task-state.v1') return taskDetails(details);
if (details.schema === 'agent-browser.v1') return browserDetails(details);
if (details.schema === 'game-assets.v1') return gameAssetDetails(details);
if (details.schema === 'runtime-context.v1') return runtimeContextDetails(details);
return null;
}
export function productToolDetailsOfResult(value: unknown) {
return productToolDetails(record(value)?.details);
}
export function isProductToolName(value: string): boolean {
return PRODUCT_TOOL_NAMES.has(value);
}
export * from '../../shared/coding-conversation-product-tool-protocol';

View File

@@ -1,82 +1 @@
import type { PublicUsage, SubagentDetailsV1 } from './contracts';
const STATUSES = new Set(['queued', 'running', 'complete', 'error', 'aborted', 'skipped']);
function recordValue(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function publicUsage(value: unknown): PublicUsage | undefined {
const usage = recordValue(value);
if (!usage
|| typeof usage.inputTokens !== 'number'
|| typeof usage.outputTokens !== 'number'
|| !Number.isFinite(usage.inputTokens)
|| !Number.isFinite(usage.outputTokens)
|| usage.inputTokens < 0
|| usage.outputTokens < 0
|| (usage.cacheReadTokens !== undefined
&& (typeof usage.cacheReadTokens !== 'number'
|| !Number.isFinite(usage.cacheReadTokens)
|| usage.cacheReadTokens < 0))
|| (usage.cacheWriteTokens !== undefined
&& (typeof usage.cacheWriteTokens !== 'number'
|| !Number.isFinite(usage.cacheWriteTokens)
|| usage.cacheWriteTokens < 0))) {
return undefined;
}
return {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
...(typeof usage.cacheReadTokens === 'number' ? { cacheReadTokens: usage.cacheReadTokens } : {}),
...(typeof usage.cacheWriteTokens === 'number' ? { cacheWriteTokens: usage.cacheWriteTokens } : {}),
};
}
export function projectSubagentDetailsV1(value: unknown): SubagentDetailsV1 | undefined {
const record = recordValue(value);
if (!record
|| record.schema !== 'subagent.v1'
|| typeof record.dispatchId !== 'string'
|| !record.dispatchId.trim()
|| !['single', 'parallel', 'chain'].includes(String(record.mode))
|| !Array.isArray(record.tasks)
|| record.tasks.length === 0
|| record.tasks.length > 8) return undefined;
const tasks: SubagentDetailsV1['tasks'] = [];
for (const value of record.tasks) {
const task = recordValue(value);
if (!task
|| typeof task.taskId !== 'string'
|| !task.taskId.trim()
|| typeof task.agentId !== 'string'
|| !task.agentId.trim()
|| (task.toolProfile !== 'read-only' && task.toolProfile !== 'coding')
|| !STATUSES.has(String(task.status))
|| (task.summary !== undefined && typeof task.summary !== 'string')
|| (task.errorCode !== undefined && typeof task.errorCode !== 'string')) return undefined;
const usage = task.usage === undefined ? undefined : publicUsage(task.usage);
if (task.usage !== undefined && !usage) return undefined;
tasks.push({
taskId: task.taskId,
agentId: task.agentId,
toolProfile: task.toolProfile,
status: task.status as SubagentDetailsV1['tasks'][number]['status'],
...(typeof task.summary === 'string' ? { summary: task.summary.slice(0, 4_000) } : {}),
...(typeof task.errorCode === 'string' ? { errorCode: task.errorCode.slice(0, 64) } : {}),
...(usage ? { usage } : {}),
});
}
return {
schema: 'subagent.v1',
dispatchId: record.dispatchId,
mode: record.mode as SubagentDetailsV1['mode'],
tasks,
};
}
export function subagentDetailsOfResult(value: unknown): SubagentDetailsV1 | undefined {
return projectSubagentDetailsV1(recordValue(value)?.details);
}
export * from '../../shared/coding-conversation-subagent-protocol';