File size: 6,886 Bytes
5710dd0 | 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 | /**
* Plan derivation from the message stream — the new-protocol replacement
* for the removed `GET /transcript/plan` endpoint.
*
* Under the message protocol there is no plan lookup endpoint; the data
* lives in the timeline itself: the EnterPlanMode/ExitPlanMode tool calls,
* the approval interaction that carries the review (its
* `request.tool_input_display` holds the `plan_review` display payload with
* the plan content, path and offered options; its `response` holds the
* decision, selected label and feedback), and the `system(plan.revision)`
* version marker (its payload path points at the plan document).
* `session.state.modes.plan` mirrors the current mode/revision over the WS
* but is not part of REST history, so derivation here runs purely over a
* history message list (in timeline order).
*/
import type {
HistoryMessage,
InteractionMessage,
ToolCallMessage,
} from '@moonshot-ai/kap-server/protocol';
export interface PlanReview {
readonly state: 'pending' | 'approved' | 'rejected' | 'cancelled';
readonly selectedOption?: string;
readonly feedback?: string;
}
export interface PlanInfo {
readonly toolCallId: string;
readonly turnId: string;
/** Which message the content was derived from. */
readonly source: 'interaction' | 'display' | 'output';
readonly plan: string;
readonly path?: string;
readonly options?: readonly { label: string; description?: string }[];
readonly review?: PlanReview;
}
export function projectPlans(
messages: readonly HistoryMessage[],
toolCallId?: string,
): PlanInfo[] {
const interactions: InteractionMessage[] = [];
const revisionPaths: string[] = [];
for (const message of messages) {
if (message.type === 'interaction') interactions.push(message);
if (message.type === 'system' && message.subtype === 'plan.revision') {
const path = readRevisionPath(message.payload);
if (path !== undefined) revisionPaths.push(path);
}
}
const plans: PlanInfo[] = [];
for (const message of messages) {
if (message.type !== 'tool_call' || message.name !== 'ExitPlanMode') continue;
if (toolCallId !== undefined && message.tool_call_id !== toolCallId) continue;
const info = projectPlanCall(message, interactions);
if (info === undefined) continue;
plans.push(
info.path === undefined && revisionPaths.length > 0
? { ...info, path: revisionPaths.at(-1) }
: info,
);
}
return plans;
}
function projectPlanCall(
call: ToolCallMessage,
interactions: readonly InteractionMessage[],
): PlanInfo | undefined {
const interaction = interactions.find(
(candidate) =>
candidate.kind === 'approval' &&
(candidate.interaction_id === call.approval_id ||
(call.approval_id === undefined && candidate.tool_call_id === call.tool_call_id)),
);
const review = readPlanReview(interaction);
if (interaction !== undefined && interaction.kind === 'approval') {
const fromInteraction = readPlanReviewDisplay(interaction.request?.tool_input_display);
if (fromInteraction !== undefined) {
return {
toolCallId: call.tool_call_id,
turnId: call.turn_id,
source: 'interaction',
...fromInteraction,
review,
};
}
}
const fromDisplay = readPlanReviewDisplay(call.display);
if (fromDisplay !== undefined) {
return {
toolCallId: call.tool_call_id,
turnId: call.turn_id,
source: 'display',
...fromDisplay,
review,
};
}
const fromOutput = parsePlanFromOutput(call.output);
if (fromOutput !== undefined) {
return {
toolCallId: call.tool_call_id,
turnId: call.turn_id,
source: 'output',
...fromOutput,
review,
};
}
return undefined;
}
function readPlanReview(interaction: InteractionMessage | undefined): PlanReview | undefined {
if (interaction === undefined || interaction.kind !== 'approval') return undefined;
const state = interaction.status;
if (state !== 'pending' && state !== 'approved' && state !== 'rejected' && state !== 'cancelled') {
return undefined;
}
const response = interaction.response;
const selected =
typeof response?.selected_label === 'string' && response.selected_label.length > 0
? response.selected_label
: undefined;
const feedback =
typeof response?.feedback === 'string' && response.feedback.length > 0
? response.feedback
: undefined;
return { state, selectedOption: selected, feedback };
}
interface PlanReviewDisplayInfo {
readonly plan: string;
readonly path?: string;
readonly options?: readonly { label: string; description?: string }[];
}
function readPlanReviewDisplay(display: unknown): PlanReviewDisplayInfo | undefined {
if (display === null || typeof display !== 'object') return undefined;
const d = display as { kind?: unknown; plan?: unknown; path?: unknown; options?: unknown };
if (d.kind !== 'plan_review' || typeof d.plan !== 'string' || d.plan.trim().length === 0) {
return undefined;
}
const options = Array.isArray(d.options)
? d.options
.map((option: unknown): { label: string; description?: string } | null => {
if (option === null || typeof option !== 'object') return null;
const o = option as { label?: unknown; description?: unknown };
if (typeof o.label !== 'string' || o.label.length === 0) return null;
return {
label: o.label,
description: typeof o.description === 'string' ? o.description : undefined,
};
})
.filter((o): o is { label: string; description?: string } => o !== null)
: undefined;
return {
plan: d.plan,
path: typeof d.path === 'string' ? d.path : undefined,
options: options !== undefined && options.length > 0 ? options : undefined,
};
}
function readRevisionPath(payload: unknown): string | undefined {
if (payload === null || typeof payload !== 'object') return undefined;
const path = (payload as { path?: unknown }).path;
return typeof path === 'string' && path.length > 0 ? path : undefined;
}
const PLAN_SAVED_TO_MARKER = 'Plan saved to: ';
const PLAN_BODY_MARKERS = ['## Approved Plan:\n', '## Plan (auto-approved, not user-reviewed):\n'];
function parsePlanFromOutput(output: unknown): { plan: string; path?: string } | undefined {
if (typeof output !== 'string') return undefined;
let path: string | undefined;
for (const line of output.split('\n')) {
if (line.startsWith(PLAN_SAVED_TO_MARKER)) {
path = line.slice(PLAN_SAVED_TO_MARKER.length).trim() || undefined;
break;
}
}
for (const marker of PLAN_BODY_MARKERS) {
const index = output.indexOf(marker);
if (index === -1) continue;
const plan = output.slice(index + marker.length);
if (plan.trim().length > 0) return { plan, path };
}
return undefined;
}
|