File size: 6,365 Bytes
f0634fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | /**
* `session/load` history replay — projects the main agent's persisted context
* history (`IAgentContextMemoryService.get()`) into an ordered batch of ACP
* `session/update` notifications so a loaded session re-renders its prior
* turns on the client.
*
* Pure projection: {@link projectHistoryToSessionUpdates} maps a
* `ContextMessage[]` to a `SessionNotification[]` with no IO, so the mapping
* is unit-testable without a live connection. The caller (`AcpSession`)
* awaits each push in order — replay is a one-shot batch whose completion
* ordering is what tells `loadSession` the response is safe to return.
*
* Turn / tool-call correlation: agent-core-v2 persists tool calls on the
* assistant message that issued them and tool results as separate `tool`-role
* messages. ACP needs a single `toolCallId` to correlate the create with its
* terminal update. Since the persisted history carries no real turn ids, the
* replay mints a synthetic monotonically-increasing `turnId` per assistant
* message and records `toolCallId → turnId` so the trailing `tool` messages
* can look up the id that issued them.
*/
import type { SessionNotification } from '@agentclientprotocol/sdk';
import type { ContentPart, ContextMessage, ToolCall } from '@moonshot-ai/agent-core-v2';
import {
assistantDeltaToSessionUpdate,
thinkingDeltaToSessionUpdate,
toolCallStartToSessionUpdate,
} from './events-map';
/**
* Project a persisted context history into an ordered batch of ACP
* `session/update` notifications. Pure — no IO, never throws on a single
* malformed message (it is skipped).
*/
export function projectHistoryToSessionUpdates(
sessionId: string,
messages: readonly ContextMessage[],
): SessionNotification[] {
const out: SessionNotification[] = [];
let turnId = 0;
const toolCallTurnIds = new Map<string, number>();
for (const message of messages) {
switch (message.role) {
case 'user':
for (const part of message.content) {
if (part.type === 'text' && part.text) {
out.push(userMessageChunk(sessionId, part.text));
}
}
break;
case 'assistant': {
turnId += 1;
for (const part of message.content) {
const update = assistantContentPartToUpdate(part, sessionId, turnId);
if (update !== null) out.push(update);
}
for (const toolCall of message.toolCalls ?? []) {
toolCallTurnIds.set(toolCall.id, turnId);
out.push(syntheticToolCall(sessionId, turnId, toolCall));
}
break;
}
case 'tool': {
const update = toolMessageToUpdate(message, sessionId, toolCallTurnIds);
if (update !== null) out.push(update);
break;
}
default:
// system / unknown roles — ACP has no analogue; skip.
break;
}
}
return out;
}
function userMessageChunk(sessionId: string, text: string): SessionNotification {
return {
sessionId,
update: {
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text },
},
};
}
function assistantContentPartToUpdate(
part: ContentPart,
sessionId: string,
turnId: number,
): SessionNotification | null {
if (part.type === 'text' && part.text) {
return assistantDeltaToSessionUpdate(sessionId, {
type: 'assistant.delta',
turnId,
delta: part.text,
});
}
if (part.type === 'think' && part.think && part.hidden !== true) {
return thinkingDeltaToSessionUpdate(sessionId, {
type: 'thinking.delta',
turnId,
delta: part.think,
});
}
// image_url / audio_url / video_url belong to the user-input side and ACP
// has no dedicated assistant-media chunk; skip them.
return null;
}
function syntheticToolCall(
sessionId: string,
turnId: number,
toolCall: ToolCall,
): SessionNotification {
return toolCallStartToSessionUpdate(sessionId, {
type: 'tool.call.started',
turnId,
toolCallId: toolCall.id,
name: toolCall.name,
args: parseToolCallArguments(toolCall.arguments),
});
}
function toolMessageToUpdate(
message: ContextMessage,
sessionId: string,
toolCallTurnIds: ReadonlyMap<string, number>,
): SessionNotification | null {
const rawToolCallId = message.toolCallId;
if (!rawToolCallId) {
// Tool result with no correlation id — skip rather than crash; the
// on-disk session is the source of truth and we cannot synthesize a
// missing id.
return null;
}
const turnId = toolCallTurnIds.get(rawToolCallId);
if (turnId === undefined) {
// The matching assistant message was not in this history slice (e.g. the
// session was compacted). Skip — emitting an update for a tool_call the
// client never saw would orphan the card.
return null;
}
const isError = message.isError === true;
return {
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: `${turnId}:${rawToolCallId}`,
status: isError ? 'failed' : 'completed',
content: toolMessageContentToAcpToolCallContent(message.content),
},
};
}
/**
* Parse a tool call's `arguments` field (a JSON string or `null`) into the
* structured object expected by {@link toolCallStartToSessionUpdate}. Falls
* back to the raw string when the payload is not valid JSON.
*/
function parseToolCallArguments(rawArguments: string | null): unknown {
if (rawArguments === null || rawArguments === '') return {};
try {
return JSON.parse(rawArguments);
} catch {
return rawArguments;
}
}
/**
* Project a `tool`-role message's content parts into the ACP
* `tool_call_update.content` shape. Text parts surface directly; anything else
* (image refs, etc.) becomes a `[type]` placeholder so the result card is not
* empty.
*/
function toolMessageContentToAcpToolCallContent(
parts: readonly ContentPart[],
): Array<{ type: 'content'; content: { type: 'text'; text: string } }> {
const result: Array<{ type: 'content'; content: { type: 'text'; text: string } }> = [];
for (const part of parts) {
if (part.type === 'text') {
if (part.text) {
result.push({ type: 'content', content: { type: 'text', text: part.text } });
}
continue;
}
result.push({ type: 'content', content: { type: 'text', text: `[${part.type}]` } });
}
return result;
}
|