feat(context): bound and persist explicit turn context

This commit is contained in:
murashit 2026-07-18 10:20:46 +09:00
parent 56a7662b2e
commit 4581ee4c5f
36 changed files with 1104 additions and 162 deletions

View file

@ -57,6 +57,7 @@ The composer lets you point Codex to relevant material without pasting it into t
When Obsidian Daily Notes or the daily section of Periodic Notes is enabled, `@today`, `@tomorrow`, and `@yesterday` resolve through its folder and date format. Paste or drop files to save them in the configured attachment folder and reference them from the same prompt. For material outside the vault, `/web <url> [message]` fetches readable page content and attaches it to the next turn without creating a note.
These references tell Codex what is relevant to the request; they do not change its vault-root working directory or its permissions.
Large transient references are bounded before they reach Codex, and the panel marks a web or thread reference when it had to truncate it.
### Guide a running turn

View file

@ -12,6 +12,8 @@ Codex owns runtime behavior and thread state: models, credentials, sandboxing, a
The panel may acquire prompt context through an explicit user action. `/web` fetches the requested page through Obsidian and attaches the extracted content as untrusted turn context; it is not a search surface or agent network policy.
Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context, while persisting only a small content-free descriptor needed to reconstruct panel display and archive metadata. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer.
Panel settings should store only panel-specific preferences. Do not mirror Codex configuration in Obsidian settings just to display or inspect it.
## Sources of Truth

View file

@ -1,3 +1,5 @@
import { splitUtf8Context, utf8ByteLength } from "../../domain/chat/context-budget";
import { type TurnContextManifestEntry, turnContextManifestText, turnContextSubmissionId } from "../../domain/chat/context-manifest";
import type { CodexInputItem } from "../../domain/chat/input";
type AppServerUserInputImageDetail = "auto" | "low" | "high" | "original";
@ -14,20 +16,85 @@ interface AppServerAdditionalContextEntry {
kind: "untrusted" | "application";
}
export function toAppServerUserInput(input: readonly CodexInputItem[]): AppServerUserInput[] {
return input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
export interface AppServerTurnInput {
input: AppServerUserInput[];
additionalContext?: Record<string, AppServerAdditionalContextEntry>;
}
export const ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES = 2_800;
export const ADDITIONAL_CONTEXT_MAX_PARTS = 8;
export function toAppServerUserInput(
input: readonly CodexInputItem[],
manifest: readonly TurnContextManifestEntry[] = [],
): AppServerUserInput[] {
const userInput = input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item));
if (manifest.length > 0) {
userInput.push({ type: "text", text: `\n${turnContextManifestText(manifest)}`, text_elements: [] });
}
return userInput;
}
export function additionalContextFromCodexInput(
input: readonly CodexInputItem[],
submissionId = "submission",
): Record<string, AppServerAdditionalContextEntry> | undefined {
return appServerTurnInputFromCodexInput(input, submissionId).additionalContext;
}
export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[], submissionId: string): AppServerTurnInput {
const additionalContext: Record<string, AppServerAdditionalContextEntry> = {};
for (const item of input) {
if (item.type !== "additionalContext") continue;
if (!item.key || !item.value) continue;
additionalContext[item.key] = { value: item.value, kind: item.kind };
const manifest: TurnContextManifestEntry[] = [];
const contexts = input.filter(
(item): item is Extract<CodexInputItem, { type: "additionalContext" }> =>
item.type === "additionalContext" && Boolean(item.key) && Boolean(item.value),
);
if (contexts.length > ADDITIONAL_CONTEXT_MAX_PARTS) {
throw new Error(`Too many additional context sources (${String(contexts.length)}).`);
}
return Object.keys(additionalContext).length > 0 ? additionalContext : undefined;
const partAllocations = allocatedPartCounts(contexts);
for (const [contextIndex, item] of contexts.entries()) {
const id = `${turnContextSubmissionId(submissionId)}.${String(contextIndex).padStart(2, "0")}`;
const sourceBytes = utf8ByteLength(item.value);
const split = splitUtf8Context(item.value, ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES, partAllocations[contextIndex] ?? 1);
const partCount = split.parts.length;
split.parts.forEach((part, partIndex) => {
const key = [
"codex_panel",
id,
safeKeyPart(item.key),
`part_${String(partIndex + 1).padStart(2, "0")}_of_${String(partCount).padStart(2, "0")}`,
].join(".");
additionalContext[key] = {
kind: item.kind,
value: [
`Codex Panel context part ${String(partIndex + 1)}/${String(partCount)}.`,
`Source: ${safeKeyPart(item.key)}`,
"",
part,
].join("\n"),
};
});
manifest.push(manifestEntry(item, id, partCount, sourceBytes, split.includedBytes));
}
return {
input: toAppServerUserInput(input, manifest),
...(Object.keys(additionalContext).length > 0 ? { additionalContext } : {}),
};
}
function allocatedPartCounts(contexts: readonly Extract<CodexInputItem, { type: "additionalContext" }>[]): number[] {
const allocations = contexts.map(() => 1);
const desired = contexts.map(
(context) => splitUtf8Context(context.value, ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES, ADDITIONAL_CONTEXT_MAX_PARTS).parts.length,
);
let remaining = ADDITIONAL_CONTEXT_MAX_PARTS - contexts.length;
for (let index = 0; index < contexts.length && remaining > 0; index += 1) {
const extra = Math.min(Math.max((desired[index] ?? 1) - 1, 0), remaining);
allocations[index] = (allocations[index] ?? 1) + extra;
remaining -= extra;
}
return allocations;
}
function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServerUserInput[] {
@ -47,6 +114,38 @@ function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServ
}
}
function manifestEntry(
item: Extract<CodexInputItem, { type: "additionalContext" }>,
id: string,
parts: number,
sourceBytes: number,
includedBytes: number,
): TurnContextManifestEntry {
const common = {
kind: item.attachment?.kind ?? "obsidian",
id,
parts,
sourceBytes,
includedBytes,
truncated: includedBytes < sourceBytes,
} as const;
if (item.attachment?.kind !== "referencedThread") return common;
return {
...common,
kind: "referencedThread",
threadId: item.attachment.threadId,
includedTurns: item.attachment.includedTurns,
turnLimit: item.attachment.turnLimit,
omittedTurns: item.attachment.omittedTurns,
truncated: common.truncated || item.attachment.truncated,
};
}
function safeKeyPart(value: string): string {
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
return safe || "context";
}
function appServerImageDetailProp(detail: Extract<CodexInputItem, { type: "image" | "localImage" }>["detail"]): {
detail?: AppServerUserInputImageDetail;
} {

View file

@ -1,3 +1,5 @@
import { referencedThreadFromManifest, userMessageContextProjection } from "../../domain/chat/context-manifest";
import { type ReferencedThreadMetadata, referencedThreadMetadataFromPrompt } from "../../domain/threads/reference";
import {
nonEmptyTurnTranscriptSummaries,
type ThreadTranscriptEntry,
@ -10,9 +12,6 @@ import type { Turn as GeneratedTurn } from "../../generated/app-server/v2/Turn";
export type TurnItem = GeneratedThreadItem;
export type TurnRecord = GeneratedTurn;
type AppServerUserInput = Extract<TurnItem, { type: "userMessage" }>["content"][number];
type AppServerTextUserInput = Extract<AppServerUserInput, { type: "text" }>;
function transcriptEntriesFromTurnRecord(turn: TurnRecord): ThreadTranscriptEntry[] {
return turn.items.flatMap((item) => transcriptEntriesFromTurnItem(item, turn));
}
@ -48,8 +47,22 @@ export function chronologicalTurnTranscriptSummariesFromTurnRecords(turns: reado
);
}
export function turnUserItemText(item: Extract<TurnItem, { type: "userMessage" }>): string {
return userInputText(item.content);
export interface TurnUserItemProjection {
text: string;
referencedThread: ReferencedThreadMetadata | null;
manifest: ReturnType<typeof userMessageContextProjection>["manifest"];
}
export function turnUserItemProjection(item: Extract<TurnItem, { type: "userMessage" }>): TurnUserItemProjection {
const projected = userMessageContextProjection(item.content, item.clientId);
const supplementalText = nonTextUserInputText(item.content, projected.text);
const text = [projected.text, supplementalText].filter(Boolean).join("\n");
const legacyReference = referencedThreadMetadataFromPrompt(text);
return {
text: legacyReference?.text ?? text,
referencedThread: referencedThreadFromManifest(projected.manifest) ?? legacyReference?.reference ?? null,
manifest: projected.manifest,
};
}
export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | null {
@ -65,8 +78,23 @@ export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | n
function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): ThreadTranscriptEntry[] {
if (item.type === "userMessage") {
const text = turnUserItemText(item).trim();
return text ? [{ kind: "user", text, timestamp: turn.startedAt }] : [];
const projection = turnUserItemProjection(item);
const text = projection.text.trim();
const contexts =
projection.manifest?.contexts
.filter((context) => context.kind === "web" || context.kind === "obsidian")
.map((context) => ({ kind: context.kind as "web" | "obsidian", truncated: context.truncated })) ?? [];
return text
? [
{
kind: "user",
text,
timestamp: turn.startedAt,
...(projection.referencedThread ? { referencedThread: projection.referencedThread } : {}),
...(contexts.length > 0 ? { contexts } : {}),
},
]
: [];
}
if (item.type === "agentMessage") {
const text = item.text.trim();
@ -79,22 +107,17 @@ function transcriptEntriesFromTurnItem(item: TurnItem, turn: TurnRecord): Thread
return [];
}
function userInputText(content: readonly AppServerUserInput[]): string {
const textItems = content.filter(isTextUserInput);
const hasText = textItems.some((item) => item.text.length > 0);
const textIncludes = (value: string) => value.length > 0 && textItems.some((item) => item.text.includes(value));
function nonTextUserInputText(content: Extract<TurnItem, { type: "userMessage" }>["content"], visibleText: string): string {
const hasText = visibleText.length > 0;
const textIncludes = (value: string) => value.length > 0 && visibleText.includes(value);
return content
.map((item) => {
if (item.type === "text") return item.text;
if (item.type === "localImage") return hasText && textIncludes(item.path) ? "" : `[local image] ${item.path}`;
if (item.type === "image") return hasText && textIncludes(item.url) ? "" : `[image] ${item.url}`;
if (item.type === "mention") return hasText ? "" : `[@${item.name}] ${item.path}`;
return hasText ? "" : `[$${item.name}] ${item.path}`;
if (item.type === "skill") return hasText ? "" : `[$${item.name}] ${item.path}`;
return "";
})
.filter(Boolean)
.join("\n");
}
function isTextUserInput(item: AppServerUserInput): item is AppServerTextUserInput {
return item.type === "text";
}

View file

@ -1,7 +1,7 @@
import type { CodexInput } from "../../domain/chat/input";
import type { ClientResponseByMethod } from "../connection/client";
import type { ClientRequestParams } from "../connection/rpc-messages";
import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
import { appServerTurnInputFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
import type { AppServerRequestClient } from "./request-client";
type AppServerTurnRuntimeOverrides = Partial<AppServerTurnRuntimeParams>;
@ -32,14 +32,14 @@ export function startTurn(
options: AppServerStartTurnOptions,
): Promise<ClientResponseByMethod["turn/start"]> {
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
const additionalContext = toAdditionalContext(input);
const prepared = toTurnInput(input, contextSubmissionId(clientUserMessageId, "user"));
const params: ClientRequestParams<"turn/start"> = {
threadId,
cwd,
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
...(additionalContext !== undefined ? { additionalContext } : {}),
...(prepared.additionalContext !== undefined ? { additionalContext: prepared.additionalContext } : {}),
...appServerTurnRuntimeParams(runtime),
input: toUserInput(input),
input: prepared.input,
};
return client.request("turn/start", params);
}
@ -72,13 +72,13 @@ export function steerTurn(
input: string | CodexInput,
clientUserMessageId?: string | null,
): Promise<unknown> {
const additionalContext = toAdditionalContext(input);
const prepared = toTurnInput(input, contextSubmissionId(clientUserMessageId, "steer"));
return client.request("turn/steer", {
threadId,
expectedTurnId,
input: toUserInput(input),
input: prepared.input,
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
...(additionalContext !== undefined ? { additionalContext } : {}),
...(prepared.additionalContext !== undefined ? { additionalContext: prepared.additionalContext } : {}),
});
}
@ -86,14 +86,9 @@ export function interruptTurn(client: AppServerRequestClient, threadId: string,
return client.request("turn/interrupt", { threadId, turnId });
}
function toUserInput(input: string | CodexInput): ClientRequestParams<"turn/start">["input"] {
if (typeof input !== "string") return toAppServerUserInput(input);
return toAppServerUserInput([{ type: "text", text: input }]);
}
function toAdditionalContext(input: string | CodexInput): ClientRequestParams<"turn/start">["additionalContext"] | undefined {
if (typeof input === "string") return undefined;
return additionalContextFromCodexInput(input);
function toTurnInput(input: string | CodexInput, submissionId: string) {
if (typeof input !== "string") return appServerTurnInputFromCodexInput(input, submissionId);
return { input: toAppServerUserInput([{ type: "text", text: input }]) };
}
function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams {
@ -105,3 +100,11 @@ function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | und
if (runtime?.approvalsReviewer !== undefined) params.approvalsReviewer = runtime.approvalsReviewer;
return params;
}
let fallbackContextSubmissionSequence = 0;
function contextSubmissionId(clientUserMessageId: string | null | undefined, kind: "user" | "steer"): string {
if (clientUserMessageId) return clientUserMessageId;
fallbackContextSubmissionSequence += 1;
return `local-${kind}-${String(Date.now())}-fallback-${String(fallbackContextSubmissionSequence)}-1`;
}

View file

@ -0,0 +1,53 @@
const encoder = new TextEncoder();
export function utf8ByteLength(value: string): number {
return encoder.encode(value).byteLength;
}
export function splitUtf8Context(value: string, maxBytes: number, maxParts: number): { parts: string[]; includedBytes: number } {
const parts: string[] = [];
let rest = value;
while (rest && parts.length < maxParts) {
const prefix = utf8Prefix(rest, maxBytes);
if (!prefix) break;
const boundary = preferredBoundary(prefix, rest.length > prefix.length);
const part = prefix.slice(0, boundary);
parts.push(part);
rest = rest.slice(part.length);
}
return { parts, includedBytes: parts.reduce((total, part) => total + utf8ByteLength(part), 0) };
}
export function truncateUtf8(value: string, maxBytes: number): string {
return utf8Prefix(value, maxBytes);
}
function utf8Prefix(value: string, maxBytes: number): string {
if (maxBytes <= 0 || !value) return "";
if (utf8ByteLength(value) <= maxBytes) return value;
let low = 0;
let high = value.length;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
const candidate = safeCodeUnitBoundary(value, middle);
if (utf8ByteLength(value.slice(0, candidate)) <= maxBytes) low = middle;
else high = middle - 1;
}
return value.slice(0, safeCodeUnitBoundary(value, low));
}
function safeCodeUnitBoundary(value: string, index: number): number {
if (index <= 0 || index >= value.length) return index;
const code = value.charCodeAt(index);
return code >= 0xdc00 && code <= 0xdfff ? index - 1 : index;
}
function preferredBoundary(prefix: string, hasRemainder: boolean): number {
if (!hasRemainder) return prefix.length;
const minimum = Math.floor(prefix.length / 2);
for (const marker of ["\n\n", "\n", " "]) {
const index = prefix.lastIndexOf(marker);
if (index >= minimum) return index + marker.length;
}
return prefix.length;
}

View file

@ -0,0 +1,154 @@
import type { ReferencedThreadMetadata } from "../threads/reference";
const TURN_CONTEXT_MANIFEST_PREFIX = "[Codex Panel context v2]";
export type TurnContextAttachment =
| {
kind: "referencedThread";
threadId: string;
includedTurns: number;
turnLimit: number;
omittedTurns: number;
truncated: boolean;
}
| { kind: "web" }
| { kind: "obsidian" };
export interface TurnContextManifestEntry {
kind: TurnContextAttachment["kind"];
id: string;
parts: number;
sourceBytes: number;
includedBytes: number;
truncated: boolean;
threadId?: string;
includedTurns?: number;
turnLimit?: number;
omittedTurns?: number;
}
export interface TurnContextManifest {
version: 2;
contexts: readonly TurnContextManifestEntry[];
}
export interface UserMessageContextProjection {
text: string;
manifest: TurnContextManifest | null;
}
export function turnContextManifestText(contexts: readonly TurnContextManifestEntry[]): string {
return `${TURN_CONTEXT_MANIFEST_PREFIX}${JSON.stringify({ version: 2, contexts } satisfies TurnContextManifest)}`;
}
export function turnContextManifestFromText(text: string): TurnContextManifest | null {
const trimmed = text.trim();
if (!trimmed.startsWith(TURN_CONTEXT_MANIFEST_PREFIX)) return null;
const json = trimmed.slice(TURN_CONTEXT_MANIFEST_PREFIX.length);
let parsed: unknown;
try {
parsed = JSON.parse(json) as unknown;
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const value = parsed as Record<string, unknown>;
if (value["version"] !== 2 || !Array.isArray(value["contexts"])) return null;
const contexts = value["contexts"].map(manifestEntryFromUnknown);
if (contexts.some((entry) => entry === null)) return null;
return { version: 2, contexts: contexts as TurnContextManifestEntry[] };
}
export function userMessageContextProjection(
content: readonly ({ type: "text"; text: string } | { type: string })[],
clientId: string | null,
): UserMessageContextProjection {
let manifest: TurnContextManifest | null = null;
const visibleText: string[] = [];
for (const [index, item] of content.entries()) {
if (item.type !== "text" || !("text" in item) || typeof item.text !== "string") continue;
const parsed =
index > 0 && index === content.length - 1 && item.text.startsWith(`\n${TURN_CONTEXT_MANIFEST_PREFIX}`)
? turnContextManifestFromText(item.text)
: null;
if (parsed && manifestMatchesClientId(parsed, clientId)) {
manifest = parsed;
continue;
}
visibleText.push(item.text);
}
return { text: visibleText.join("\n"), manifest };
}
export function turnContextSubmissionId(value: string): string {
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
return safe || "context";
}
function manifestMatchesClientId(manifest: TurnContextManifest, clientId: string | null): boolean {
if (!clientId || !/^local-(?:user|steer)-\d+-[A-Za-z0-9_-]+-[a-z0-9]+-[a-z0-9]+$/.test(clientId)) return false;
const submissionId = turnContextSubmissionId(clientId);
return manifest.contexts.every((context, index) => context.id === `${submissionId}.${String(index).padStart(2, "0")}`);
}
export function referencedThreadFromManifest(manifest: TurnContextManifest | null): ReferencedThreadMetadata | null {
const context = manifest?.contexts.find((entry) => entry.kind === "referencedThread");
if (!context?.threadId || context.includedTurns === undefined || context.turnLimit === undefined || context.omittedTurns === undefined) {
return null;
}
return {
threadId: context.threadId,
title: context.threadId.slice(0, 8),
includedTurns: context.includedTurns,
turnLimit: context.turnLimit,
omittedTurns: context.omittedTurns,
truncated: context.truncated,
};
}
function manifestEntryFromUnknown(input: unknown): TurnContextManifestEntry | null {
if (!input || typeof input !== "object") return null;
const value = input as Record<string, unknown>;
const kind = contextKind(value["kind"]);
const id = stringValue(value["id"]);
const parts = nonNegativeInteger(value["parts"]);
const sourceBytes = nonNegativeInteger(value["sourceBytes"]);
const includedBytes = nonNegativeInteger(value["includedBytes"]);
const truncated = value["truncated"];
if (!kind || !id || parts === null || sourceBytes === null || includedBytes === null || typeof truncated !== "boolean") return null;
if (kind !== "referencedThread") return { kind, id, parts, sourceBytes, includedBytes, truncated };
const threadId = stringValue(value["threadId"]);
const includedTurns = nonNegativeInteger(value["includedTurns"]);
const turnLimit = positiveInteger(value["turnLimit"]);
const omittedTurns = nonNegativeInteger(value["omittedTurns"]);
if (!threadId || includedTurns === null || turnLimit === null || omittedTurns === null) return null;
return {
kind,
id,
parts,
sourceBytes,
includedBytes,
truncated,
threadId,
includedTurns,
turnLimit,
omittedTurns,
};
}
function contextKind(value: unknown): TurnContextAttachment["kind"] | null {
return value === "referencedThread" || value === "web" || value === "obsidian" ? value : null;
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.length > 0 && value.length <= 160 ? value : null;
}
function nonNegativeInteger(value: unknown): number | null {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
}
function positiveInteger(value: unknown): number | null {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
}

View file

@ -9,6 +9,7 @@ export interface RequestAdditionalContext {
key: string;
value: string;
kind: "untrusted" | "application";
attachment?: TurnContextAttachment;
}
export type CodexInputItem =
@ -17,7 +18,13 @@ export type CodexInputItem =
| { type: "localImage"; path: string; detail?: UserInputImageDetail }
| { type: "skill"; name: string; path: string }
| { type: "mention"; name: string; path: string }
| { type: "additionalContext"; key: string; value: string; kind: RequestAdditionalContext["kind"] };
| {
type: "additionalContext";
key: string;
value: string;
kind: RequestAdditionalContext["kind"];
attachment?: TurnContextAttachment;
};
export type CodexInput = CodexInputItem[];
type UserInputImageDetail = "auto" | "low" | "high" | "original";
@ -41,6 +48,7 @@ export function codexTextInputWithMentions(
key: context.key,
value: context.value,
kind: context.kind,
...(context.attachment ? { attachment: context.attachment } : {}),
})),
];
}
@ -48,3 +56,5 @@ export function codexTextInputWithMentions(
export function codexTextInputWithAttachments(text: string, input: readonly CodexInputItem[]): CodexInput {
return [...codexTextInput(text), ...input.filter((item) => item.type !== "text")];
}
import type { TurnContextAttachment } from "./context-manifest";

View file

@ -99,18 +99,21 @@ function markdownLinesFromTranscriptEntry(entry: ThreadTranscriptEntry): string[
switch (entry.kind) {
case "user": {
const heading = timestampedHeading("User", entry.timestamp);
const referenced = referencedThreadMetadataFromPrompt(entry.text);
if (referenced) {
return [
heading,
"",
referenced.text,
"",
`> Referenced: ${referenced.reference.title} (${String(referenced.reference.includedTurns)}/${String(referenced.reference.turnLimit)} turns, ${referenced.reference.threadId})`,
"",
];
}
return [heading, "", entry.text, ""];
const legacyReferenced = referencedThreadMetadataFromPrompt(entry.text);
const referenced = entry.referencedThread ? { text: entry.text, reference: entry.referencedThread } : legacyReferenced;
return [
heading,
"",
referenced?.text ?? entry.text,
"",
...(referenced
? [
`> Referenced: ${referenced.reference.title} (${String(referenced.reference.includedTurns)}/${String(referenced.reference.turnLimit)} turns${referenced.reference.truncated ? ", truncated" : ""}, ${referenced.reference.threadId})`,
"",
]
: []),
...(entry.contexts ?? []).flatMap((context) => [`> Context: ${archiveContextLabel(context, entry.text)}`, ""]),
];
}
case "assistant":
return [timestampedHeading("Codex", entry.timestamp), "", entry.text, ""];
@ -119,6 +122,23 @@ function markdownLinesFromTranscriptEntry(entry: ThreadTranscriptEntry): string[
}
}
function archiveContextLabel(context: NonNullable<ThreadTranscriptEntry["contexts"]>[number], visibleText: string): string {
const truncated = context.truncated ? " (truncated)" : "";
if (context.kind === "obsidian") return `Obsidian context${truncated}`;
const url = visibleWebUrl(visibleText);
return `Web page${truncated}${url ? ` (${url})` : ""}`;
}
function visibleWebUrl(text: string): string | null {
const firstToken = text.trim().split(/\s+/, 1)[0] ?? "";
try {
const parsed = new URL(firstToken);
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.toString() : null;
} catch {
return null;
}
}
function timestampedHeading(label: string, unixSeconds: number | null): string {
const timestamp = formatUnixTimestamp(unixSeconds);
return timestamp ? `## ${label} - ${timestamp}` : `## ${label}`;

View file

@ -1,3 +1,4 @@
import { truncateUtf8, utf8ByteLength } from "../chat/context-budget";
import type { Thread } from "./model";
import { threadDisplayTitle } from "./title";
import type { TurnTranscriptSummary } from "./transcript";
@ -9,6 +10,8 @@ export interface ReferencedThreadMetadata {
title: string;
includedTurns: number;
turnLimit: number;
omittedTurns?: number;
truncated?: boolean;
}
interface ReferencedThreadEnvelope {
@ -17,33 +20,12 @@ interface ReferencedThreadEnvelope {
visibleText: string;
}
export interface ReferencedThreadPromptBundle {
prompt: string;
export interface ReferencedThreadContextBundle {
value: string;
referencedThread: ReferencedThreadMetadata;
}
function referencedThreadPrompt(thread: Thread, turns: readonly TurnTranscriptSummary[], userRequest: string): string {
const reference = referencedThreadMetadata(thread, turns.length);
const envelope = referencedThreadEnvelope(reference, userRequest);
return [
REFERENCED_THREAD_ENVELOPE_START,
JSON.stringify(envelopeMetadata(envelope)),
"",
"Reference thread history:",
...turns.flatMap((turn, index) => {
const lines = [`Turn ${String(index + 1)}:`];
if (turn.userText) lines.push(`User:\n${turn.userText}`);
if (turn.assistantText) lines.push(`Codex:\n${turn.assistantText}`);
return ["", ...lines];
}),
"",
REFERENCED_THREAD_ENVELOPE_END,
"",
"Current user request:",
envelope.visibleText,
].join("\n");
}
const REFERENCED_THREAD_CONTEXT_MAX_BYTES = 18_000;
function referencedThreadMetadata(thread: Thread, count: number): ReferencedThreadMetadata {
return {
@ -54,16 +36,76 @@ function referencedThreadMetadata(thread: Thread, count: number): ReferencedThre
};
}
export function referencedThreadPromptBundle(
thread: Thread,
turns: readonly TurnTranscriptSummary[],
userRequest: string,
): ReferencedThreadPromptBundle {
const prompt = referencedThreadPrompt(thread, [...turns], userRequest);
return {
prompt,
referencedThread: referencedThreadMetadata(thread, turns.length),
export function referencedThreadContextBundle(thread: Thread, turns: readonly TurnTranscriptSummary[]): ReferencedThreadContextBundle {
const rendered = turns.map((turn, index) => renderedReferenceTurn(turn, index + 1));
const included: string[] = [];
let bytes = 0;
let truncatedTurn = false;
for (let index = rendered.length - 1; index >= 0; index -= 1) {
const value = rendered[index];
if (value === undefined) continue;
const nextBytes = utf8ByteLength(value);
if (bytes + nextBytes > REFERENCED_THREAD_CONTEXT_MAX_BYTES) {
if (included.length === 0) {
const turn = turns[index];
if (turn) included.unshift(truncatedReferenceTurn(turn, index + 1, REFERENCED_THREAD_CONTEXT_MAX_BYTES));
truncatedTurn = true;
}
break;
}
included.unshift(value);
bytes += nextBytes;
}
const omittedTurns = turns.length - included.length;
const reference = {
...referencedThreadMetadata(thread, included.length),
omittedTurns,
truncated: omittedTurns > 0 || truncatedTurn,
};
return {
value: [
"Referenced thread context for the current user input:",
`Thread: ${thread.id}`,
`Included turns: ${String(reference.includedTurns)}`,
`Omitted turns: ${String(omittedTurns)}`,
"",
...included,
].join("\n"),
referencedThread: reference,
};
}
function renderedReferenceTurn(turn: TurnTranscriptSummary, index: number): string {
const lines = [`Turn ${String(index)}:`];
if (turn.userText) lines.push(`User:\n${turn.userText}`);
if (turn.assistantText) lines.push(`Codex:\n${turn.assistantText}`);
return `${lines.join("\n")}\n\n`;
}
function truncatedReferenceTurn(turn: TurnTranscriptSummary, index: number, maxBytes: number): string {
const user = turn.userText ?? "";
const assistant = turn.assistantText ?? "";
const prefix = `Turn ${String(index)}:\n`;
const userLabel = user ? "User:\n" : "";
const assistantLabel = assistant ? "Codex:\n" : "";
const separator = user && assistant ? "\n" : "";
const suffix = "\n[Turn fields truncated]\n\n";
const fixedBytes = utf8ByteLength(`${prefix}${userLabel}${separator}${assistantLabel}${suffix}`);
const available = Math.max(maxBytes - fixedBytes, 0);
const userBytes = utf8ByteLength(user);
const assistantBytes = utf8ByteLength(assistant);
let assistantBudget = assistant ? Math.min(assistantBytes, Math.floor(available / 2)) : 0;
let userBudget = user ? Math.min(userBytes, available - assistantBudget) : 0;
const remaining = available - userBudget - assistantBudget;
assistantBudget += Math.min(Math.max(assistantBytes - assistantBudget, 0), remaining);
userBudget += Math.min(Math.max(userBytes - userBudget, 0), available - userBudget - assistantBudget);
return [
prefix,
user ? `${userLabel}${truncateUtf8(user, userBudget)}` : "",
separator,
assistant ? `${assistantLabel}${truncateUtf8(assistant, assistantBudget)}` : "",
suffix,
].join("");
}
export function referencedThreadMetadataFromPrompt(text: string): { text: string; reference: ReferencedThreadMetadata } | null {
@ -82,34 +124,15 @@ interface ReferencedThreadEnvelopeMetadata {
turnLimit: number;
}
function referencedThreadEnvelope(reference: ReferencedThreadMetadata, visibleText: string): ReferencedThreadEnvelope {
return {
version: 1,
reference,
visibleText,
};
}
function envelopeMetadata(envelope: ReferencedThreadEnvelope): ReferencedThreadEnvelopeMetadata {
return {
version: envelope.version,
threadId: envelope.reference.threadId,
title: envelope.reference.title,
includedTurns: envelope.reference.includedTurns,
turnLimit: envelope.reference.turnLimit,
};
}
function referencedThreadEnvelopeFromPrompt(text: string): ReferencedThreadEnvelope | null {
const headerStart = text.indexOf(REFERENCED_THREAD_ENVELOPE_START);
const headerEnd = text.indexOf(REFERENCED_THREAD_ENVELOPE_END);
const requestMarker = "\nCurrent user request:\n";
const requestStart = text.indexOf(requestMarker);
if (headerStart !== 0 || headerEnd === -1 || requestStart === -1 || requestStart < headerEnd) return null;
const requestBoundary = `\n${REFERENCED_THREAD_ENVELOPE_END}\n\nCurrent user request:\n`;
const boundaryStart = text.lastIndexOf(requestBoundary);
if (headerStart !== 0 || boundaryStart === -1) return null;
const metadataText = firstNonEmptyLine(text.slice(REFERENCED_THREAD_ENVELOPE_START.length, headerEnd));
const metadataText = firstNonEmptyLine(text.slice(REFERENCED_THREAD_ENVELOPE_START.length, boundaryStart));
const metadata = referencedThreadEnvelopeMetadataFromJson(metadataText);
const visibleText = text.slice(requestStart + requestMarker.length).trim();
const visibleText = text.slice(boundaryStart + requestBoundary.length).trim();
if (!metadata || !visibleText) return null;
return {
version: 1,

View file

@ -4,6 +4,13 @@ export interface ThreadTranscriptEntry {
kind: ThreadTranscriptEntryKind;
text: string;
timestamp: number | null;
referencedThread?: ReferencedThreadMetadata;
contexts?: readonly ThreadTranscriptContext[];
}
interface ThreadTranscriptContext {
kind: "web" | "obsidian";
truncated: boolean;
}
export interface TurnTranscriptSummary {
@ -46,3 +53,5 @@ function lastTranscriptText(
}
return null;
}
import type { ReferencedThreadMetadata } from "./reference";

View file

@ -2,12 +2,12 @@ import {
completedTurnTranscriptSummaryFromTurnRecord,
type TurnItem,
type TurnRecord,
turnUserItemText,
turnUserItemProjection,
} from "../../../../../app-server/protocol/turn";
import { jsonPreview } from "../../../../../domain/display/json-preview";
import type { HistoricalTurn } from "../../../../../domain/threads/history";
import { referencedThreadMetadataFromPrompt } from "../../../../../domain/threads/reference";
import type { TurnTranscriptSummary } from "../../../../../domain/threads/transcript";
import { contextAttachmentsFromManifest } from "../../../domain/thread-stream/format/context-attachments";
import { fileMentionsFromInput } from "../../../domain/thread-stream/format/file-mentions";
import { normalizeProposedPlanMarkdown } from "../../../domain/thread-stream/format/proposed-plan";
import { userMessageDisplayText } from "../../../domain/thread-stream/format/user-message-text";
@ -130,20 +130,23 @@ function turnItemSourceFields(item: { id: string }, turnId?: string): TurnItemSo
}
function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStreamItem {
const text = turnUserItemText(item);
const referencedThread = referencedThreadMetadataFromPrompt(text);
const projection = turnUserItemProjection(item);
const text = projection.text;
const referencedThread = projection.referencedThread;
const mentionedFiles = fileMentionsFromInput(item.content);
const contextAttachments = contextAttachmentsFromManifest(projection.manifest, text);
if (referencedThread) {
return {
...turnItemSourceFields(item, turnId),
kind: "dialogue",
dialogueKind: "user",
role: "user",
text: userMessageDisplayText(referencedThread.text, item.content),
copyText: referencedThread.text,
referencedThread: referencedThread.reference,
text: userMessageDisplayText(text, item.content),
copyText: text,
referencedThread,
...definedProp("clientId", item.clientId),
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
...(contextAttachments.length > 0 ? { contextAttachments } : {}),
};
}
return {
@ -155,6 +158,7 @@ function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStr
copyText: text,
...definedProp("clientId", item.clientId),
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
...(contextAttachments.length > 0 ? { contextAttachments } : {}),
};
}

View file

@ -3,7 +3,7 @@ import { readReferencedThreadTurnTranscriptSummaries } from "../../../app-server
import { type CodexInput, codexTextInputWithAttachments } from "../../../domain/chat/input";
import { shortThreadId } from "../../../domain/threads/id";
import type { Thread } from "../../../domain/threads/model";
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadPromptBundle } from "../../../domain/threads/reference";
import { REFERENCED_THREAD_TURN_LIMIT, referencedThreadContextBundle } from "../../../domain/threads/reference";
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
import type { ThreadReferenceInput } from "../application/turns/slash-command-execution";
@ -40,11 +40,27 @@ async function referencedThreadInput(
return null;
}
const messageInput = host.prepareInput(message, snapshot);
const reference = referencedThreadPromptBundle(thread, turns, messageInput.text);
const reference = referencedThreadContextBundle(thread, turns);
host.setStatus(`Referencing ${shortThreadId(thread.id)} (${String(turns.length)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`);
return {
text: messageInput.text,
input: codexTextInputWithAttachments(reference.prompt, messageInput.input),
input: codexTextInputWithAttachments(messageInput.text, [
{
type: "additionalContext",
key: "codex_panel_referenced_thread",
kind: "untrusted",
value: reference.value,
attachment: {
kind: "referencedThread",
threadId: reference.referencedThread.threadId,
includedTurns: reference.referencedThread.includedTurns,
turnLimit: reference.referencedThread.turnLimit,
omittedTurns: reference.referencedThread.omittedTurns ?? 0,
truncated: reference.referencedThread.truncated ?? false,
},
},
...messageInput.input,
]),
referencedThread: reference.referencedThread,
};
} catch (error) {

View file

@ -12,10 +12,15 @@ export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationIn
const { currentItems, completedTurnId, turnItems } = input;
if (turnItems.length === 0) return currentItems;
const contextAttachmentsByClientId = new Map(
const localMetadataByClientId = new Map(
currentItems.flatMap((item) =>
isOptimisticUserDialogue(item) && item.contextAttachments
? [[optimisticDialogueClientId(item), item.contextAttachments] as const]
isOptimisticUserDialogue(item)
? [
[
optimisticDialogueClientId(item),
{ contextAttachments: item.contextAttachments, referencedThread: item.referencedThread },
] as const,
]
: [],
),
);
@ -25,10 +30,17 @@ export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationIn
: fallbackContextAttachmentsByText(currentItems, completedTurnId);
const turnItemsWithLocalContext = turnItems.map((item) => {
if (!isUserDialogue(item)) return item;
const contextAttachments = item.clientId
? contextAttachmentsByClientId.get(item.clientId)
: contextAttachmentsByFallbackText.get(item.copyText ?? item.text);
return contextAttachments ? { ...item, contextAttachments } : item;
const localMetadata = item.clientId ? localMetadataByClientId.get(item.clientId) : undefined;
const contextAttachments =
item.contextAttachments ?? localMetadata?.contextAttachments ?? contextAttachmentsByFallbackText.get(item.copyText ?? item.text);
const referencedThread = item.referencedThread
? { ...item.referencedThread, ...(localMetadata?.referencedThread ? { title: localMetadata.referencedThread.title } : {}) }
: localMetadata?.referencedThread;
return {
...item,
...(contextAttachments ? { contextAttachments } : {}),
...(referencedThread ? { referencedThread } : {}),
};
});
const serverUserDialogues = turnItemsWithLocalContext.filter(isUserDialogue);

View file

@ -1,3 +1,4 @@
import type { TurnContextManifest } from "../../../../../domain/chat/context-manifest";
import type { CodexInputItem } from "../../../../../domain/chat/input";
import type { ThreadStreamContextAttachment } from "../items";
@ -5,14 +6,37 @@ export const WEB_CONTEXT_KEY = "codex_panel_web_context";
export function contextAttachmentsFromInput(input: readonly CodexInputItem[]): ThreadStreamContextAttachment[] {
return input.flatMap((item) => {
if (item.type !== "additionalContext" || item.key !== WEB_CONTEXT_KEY) return [];
if (item.type !== "additionalContext" || (item.attachment?.kind !== "web" && item.key !== WEB_CONTEXT_KEY)) return [];
const source = webContextSource(item.value);
return [{ label: "Web page", ...(source ? { detail: source } : {}) }];
});
}
export function contextAttachmentsFromManifest(manifest: TurnContextManifest | null, visibleText: string): ThreadStreamContextAttachment[] {
const attachments: ThreadStreamContextAttachment[] = [];
const web = manifest?.contexts.find((context) => context.kind === "web");
if (web) {
const source = visibleWebSource(visibleText);
attachments.push({ label: web.truncated ? "Web page (truncated)" : "Web page", ...(source ? { detail: source } : {}) });
}
if (manifest?.contexts.some((context) => context.kind === "obsidian" && context.truncated)) {
attachments.push({ label: "Obsidian context (truncated)" });
}
return attachments;
}
function webContextSource(value: string): string | null {
const sourceLine = value.split("\n").find((line) => line.startsWith("Source:"));
const source = sourceLine?.slice("Source:".length).trim() ?? "";
return source || null;
}
function visibleWebSource(text: string): string | null {
const firstToken = text.trim().split(/\s+/, 1)[0] ?? "";
try {
const parsed = new URL(firstToken);
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.toString() : null;
} catch {
return null;
}
}

View file

@ -156,6 +156,7 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan
},
archiveDestination: environment.obsidian.archiveDestination,
catalog: environment.plugin.threadCatalog,
referenceThreads: () => stateStore.getState().threadList.listedThreads,
notice: (text) => {
new Notice(text);
},

View file

@ -49,8 +49,8 @@ async function readUrlToInput(
return {
text,
input: codexTextInputWithAttachments(text, [
{ type: "additionalContext", key: WEB_CONTEXT_KEY, kind: "untrusted", value: context, attachment: { kind: "web" } },
...messageInput.input,
{ type: "additionalContext", key: WEB_CONTEXT_KEY, kind: "untrusted", value: context },
]),
};
}

View file

@ -31,6 +31,7 @@ export interface ChatPanelGoalModel {
}
export interface ChatPanelThreadStreamModel {
readonly threads: ChatState["threadList"]["listedThreads"];
readonly activeThreadId: string | null;
readonly activeThreadCwd: ChatActiveThreadState["cwd"];
readonly forkAllowed: boolean;
@ -104,6 +105,7 @@ export function selectChatPanelGoal(state: ChatState): ChatPanelGoalModel {
export function selectChatPanelThreadStream(state: ChatState): ChatPanelThreadStreamModel {
const activeThread = activeThreadState(state);
return {
threads: state.threadList.listedThreads,
activeThreadId: activeThread?.id ?? null,
activeThreadCwd: activeThread?.cwd ?? null,
forkAllowed: activePanelOperationDecision(state, "fork").kind === "allowed",

View file

@ -1,3 +1,4 @@
import { threadDisplayTitle } from "../../../../domain/threads/title";
import type { TurnDiffViewState } from "../../../turn-diff/model";
import { type PendingRequestBlockActions, pendingRequestBlockStateFromRequestState } from "../../application/pending-requests/block";
import type { ChatRequestState } from "../../application/pending-requests/state";
@ -132,13 +133,15 @@ function threadStreamStateProjection(
context: ChatThreadStreamSurfaceContext,
): ThreadStreamStateProjection {
const canonicalItems = threadStreamItems(model.threadStream);
const stableItems = model.threadStream.activeSegment
const stableItemsRaw = model.threadStream.activeSegment
? threadStreamStableItems(model.threadStream)
: appendPendingSubmission(threadStreamStableItems(model.threadStream), model.pendingSubmission);
const activeItems = model.threadStream.activeSegment
const activeItemsRaw = model.threadStream.activeSegment
? appendPendingSubmission(threadStreamActiveItems(model.threadStream), model.pendingSubmission)
: threadStreamActiveItems(model.threadStream);
const items = appendPendingSubmission(canonicalItems, model.pendingSubmission);
const items = resolvedReferenceTitles(appendPendingSubmission(canonicalItems, model.pendingSubmission), model.threads);
const stableItems = resolvedReferenceTitles(stableItemsRaw, model.threads);
const activeItems = resolvedReferenceTitles(activeItemsRaw, model.threads);
const disclosures = {
details: model.disclosureDetails,
activityGroups: model.disclosureActivityGroups,
@ -184,6 +187,18 @@ function threadStreamStateProjection(
};
}
function resolvedReferenceTitles(
items: readonly ThreadStreamItem[],
threads: ChatPanelThreadStreamModel["threads"],
): readonly ThreadStreamItem[] {
const byId = new Map(threads.map((thread) => [thread.id, thread] as const));
return items.map((item) => {
if (item.kind !== "dialogue" || !item.referencedThread) return item;
const thread = byId.get(item.referencedThread.threadId);
return thread ? { ...item, referencedThread: { ...item.referencedThread, title: threadDisplayTitle(thread) } } : item;
});
}
function appendPendingSubmission(
items: readonly ThreadStreamItem[],
pendingSubmission: ChatPanelThreadStreamModel["pendingSubmission"],

View file

@ -22,6 +22,7 @@ export interface ReferencedThreadTextView {
title: string;
includedTurns: number;
turnLimit: number;
truncated?: boolean;
}
export interface ContextItemTextView {

View file

@ -138,6 +138,7 @@ function ReferencedThread({ reference }: { reference: ReferencedThreadTextView }
<span className="codex-panel__edited-files-separator">·</span>
<span>
{reference.includedTurns}/{reference.turnLimit} turns
{reference.truncated ? " · truncated" : ""}
</span>
</span>
</div>

View file

@ -109,6 +109,7 @@ export class ThreadsViewSession {
},
archiveDestination: () => this.environment.archiveDestination(),
catalog: this.host.threadCatalog,
referenceThreads: () => this.threads,
notice: (message) => {
new Notice(message);
},

View file

@ -1,6 +1,7 @@
import type { AppServerQueryContextIdentity } from "../../../app-server/query/keys";
import type { ArchiveExportSettings } from "../../../domain/threads/archive-markdown";
import { normalizeExplicitThreadName } from "../../../domain/threads/model";
import { normalizeExplicitThreadName, type Thread } from "../../../domain/threads/model";
import { threadDisplayTitle } from "../../../domain/threads/title";
import type { ThreadCatalogEventSink } from "../catalog/thread-catalog";
import { type ArchiveExportDestination, exportArchivedThreadMarkdown } from "./archive-export";
import type { ThreadOperationsTransport } from "./ports";
@ -21,6 +22,7 @@ export interface ThreadOperationsHost {
};
archiveDestination(): ArchiveExportDestination;
catalog: ThreadCatalogEventSink;
referenceThreads(): readonly Thread[];
notice(message: string): void;
}
@ -81,8 +83,17 @@ async function archiveThread(
shouldExport
? async (thread) => {
const archiveSettings = host.archiveExport.settings();
const threads = host.referenceThreads();
const titleById = new Map(threads.map((item) => [item.id, threadDisplayTitle(item)] as const));
const result = await exportArchivedThreadMarkdown(
thread,
{
...thread,
transcriptEntries: thread.transcriptEntries.map((entry) => {
if (!entry.referencedThread) return entry;
const title = titleById.get(entry.referencedThread.threadId);
return title ? { ...entry, referencedThread: { ...entry.referencedThread, title } } : entry;
}),
},
{
...archiveSettings,
vaultPath: host.archiveExport.vaultPath,

View file

@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { additionalContextFromCodexInput, toAppServerUserInput } from "../../src/app-server/protocol/request-input";
import {
ADDITIONAL_CONTEXT_MAX_PARTS,
ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES,
additionalContextFromCodexInput,
appServerTurnInputFromCodexInput,
toAppServerUserInput,
} from "../../src/app-server/protocol/request-input";
import { utf8ByteLength } from "../../src/domain/chat/context-budget";
import { turnContextManifestFromText } from "../../src/domain/chat/context-manifest";
import { type CodexInput, codexTextInputWithAttachments, codexTextInputWithMentions } from "../../src/domain/chat/input";
describe("app-server request input", () => {
@ -56,8 +64,76 @@ describe("app-server request input", () => {
{ type: "text", text: "Use [[Note]]", text_elements: [] },
{ type: "mention", name: "Note", path: "Note.md" },
]);
expect(additionalContextFromCodexInput(input)).toEqual({
codex_panel_obsidian_context: { kind: "untrusted", value: "- [[Note]] -> Note.md" },
expect(additionalContextFromCodexInput(input, "local-user")).toEqual({
"codex_panel.local-user.00.codex_panel_obsidian_context.part_01_of_01": {
kind: "untrusted",
value: "Codex Panel context part 1/1.\nSource: codex_panel_obsidian_context\n\n- [[Note]] -> Note.md",
},
});
});
it("chunks UTF-8 context below the upstream value cap and persists an inert manifest", () => {
const value = `見出し\n\n${"本文です。".repeat(2_000)}`;
const prepared = appServerTurnInputFromCodexInput(
[
{ type: "text", text: "要約して" },
{
type: "additionalContext",
key: "codex_panel_web_context",
kind: "untrusted",
value,
attachment: { kind: "web" },
},
],
"local-user-1",
);
const entries = Object.entries(prepared.additionalContext ?? {});
expect(entries).toHaveLength(ADDITIONAL_CONTEXT_MAX_PARTS);
expect(entries.map(([key]) => key)).toEqual([...entries.map(([key]) => key)].sort());
expect(entries.every(([, entry]) => utf8ByteLength(entry.value) < 4_000)).toBe(true);
expect(entries.every(([, entry]) => entry.value.includes("Codex Panel context part"))).toBe(true);
expect(ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES).toBeLessThan(4_000);
const manifestInput = prepared.input.at(-1);
expect(manifestInput?.type).toBe("text");
const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text) : null;
expect(manifest?.contexts).toEqual([
expect.objectContaining({
kind: "web",
parts: ADDITIONAL_CONTEXT_MAX_PARTS,
truncated: true,
sourceBytes: utf8ByteLength(value),
}),
]);
expect(manifestInput?.type === "text" ? manifestInput.text : "").not.toContain("本文です");
});
it("namespaces identical explicit context by submission", () => {
const input: CodexInput = [
{ type: "text", text: "read it" },
{ type: "additionalContext", key: "same", kind: "untrusted", value: "same" },
];
const first = Object.keys(additionalContextFromCodexInput(input, "first") ?? {});
const second = Object.keys(additionalContextFromCodexInput(input, "second") ?? {});
expect(first).not.toEqual(second);
});
it("reserves at least one part for every explicit context source", () => {
const prepared = appServerTurnInputFromCodexInput(
[
{ type: "text", text: "compare them" },
{ type: "additionalContext", key: "web", kind: "untrusted", value: "w".repeat(30_000), attachment: { kind: "web" } },
{ type: "additionalContext", key: "selection", kind: "untrusted", value: "selected text" },
],
"local-user",
);
const entries = Object.entries(prepared.additionalContext ?? {});
expect(entries).toHaveLength(ADDITIONAL_CONTEXT_MAX_PARTS);
expect(entries.some(([key, entry]) => key.includes(".selection.") && entry.value.includes("selected text"))).toBe(true);
const manifestInput = prepared.input.at(-1);
const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text) : null;
expect(manifest?.contexts.map((context) => context.parts)).toEqual([7, 1]);
});
});

View file

@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import { type ArchiveExportSettings, archivedThreadMarkdown } from "../../../src/domain/threads/archive-markdown";
import type { Thread } from "../../../src/domain/threads/model";
import { referencedThreadPromptBundle } from "../../../src/domain/threads/reference";
import type { ThreadTranscriptEntry } from "../../../src/domain/threads/transcript";
import { referencedThreadV1Fixture } from "../../helpers/referenced-thread-v1";
describe("thread archive export", () => {
it("writes frontmatter and readable user/codex turns with turn timestamps", () => {
@ -103,7 +103,7 @@ describe("thread archive export", () => {
});
it("hides embedded /refer context and keeps a compact reference line", () => {
const { prompt } = referencedThreadPromptBundle(
const prompt = referencedThreadV1Fixture(
thread({ id: "thread-ref", name: "参照元" }),
[{ userText: "元の依頼", assistantText: "回答" }],
"続きです",
@ -117,6 +117,34 @@ describe("thread archive export", () => {
expect(output).not.toContain("元の依頼");
});
it("renders persisted v2 reference metadata without embedding its payload", () => {
const entry = transcriptEntry("user", "続きです", 1);
entry.referencedThread = {
threadId: "thread-ref",
title: "thread-r",
includedTurns: 2,
turnLimit: 20,
omittedTurns: 3,
truncated: true,
};
const output = exportedMarkdown(thread({ transcriptEntries: [entry] }), new Date(2026, 4, 18));
expect(output).toContain("続きです");
expect(output).toContain("> Referenced: thread-r (2/20 turns, truncated, thread-ref)");
});
it("renders persisted web and Obsidian context metadata", () => {
const entry = transcriptEntry("user", "https://example.com/article summarize", 1);
entry.contexts = [
{ kind: "web", truncated: true },
{ kind: "obsidian", truncated: false },
];
const output = exportedMarkdown(thread({ transcriptEntries: [entry] }), new Date(2026, 4, 18));
expect(output).toContain("> Context: Web page (truncated) (https://example.com/article)");
expect(output).toContain("> Context: Obsidian context");
});
it("writes optional frontmatter tags from fixed comma-separated settings", () => {
const output = exportedMarkdown(thread({ name: "Tagged thread" }), new Date(2026, 4, 18), {
archiveExportTags: '#codex, "archive", codex, {{title}}',

View file

@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import type { Thread } from "../../../src/domain/threads/model";
import { referencedThreadMetadataFromPrompt, referencedThreadPromptBundle } from "../../../src/domain/threads/reference";
import { referencedThreadContextBundle, referencedThreadMetadataFromPrompt } from "../../../src/domain/threads/reference";
import { referencedThreadV1Fixture } from "../../helpers/referenced-thread-v1";
function thread(overrides: Partial<Thread> = {}): Thread {
return {
@ -19,7 +20,7 @@ function thread(overrides: Partial<Thread> = {}): Thread {
describe("thread reference context", () => {
it("builds an untruncated reference prompt with the 20 turn limit noted", () => {
const longText = "x".repeat(5000);
const { prompt } = referencedThreadPromptBundle(thread(), [{ userText: longText, assistantText: "回答" }], "この続きです");
const prompt = referencedThreadV1Fixture(thread(), [{ userText: longText, assistantText: "回答" }], "この続きです");
expect(prompt).toContain("[Codex Panel referenced thread v1]");
expect(prompt).toContain('"version":1');
@ -30,7 +31,7 @@ describe("thread reference context", () => {
});
it("extracts display text and metadata from a reference prompt", () => {
const { prompt } = referencedThreadPromptBundle(thread(), [{ userText: "元の依頼", assistantText: "回答" }], "この続きです");
const prompt = referencedThreadV1Fixture(thread(), [{ userText: "元の依頼", assistantText: "回答" }], "この続きです");
expect(referencedThreadMetadataFromPrompt(prompt)).toEqual({
text: "この続きです",
@ -101,11 +102,53 @@ describe("thread reference context", () => {
).toBeNull();
});
it("builds a prompt bundle for slash command references", () => {
const source = thread();
const input = referencedThreadPromptBundle(source, [{ userText: "元の依頼", assistantText: "回答" }], "この続きです");
it("keeps the newest complete turns within the reference budget", () => {
const turns = [
{ userText: `old-${"a".repeat(10_000)}`, assistantText: "old answer" },
{ userText: `middle-${"b".repeat(10_000)}`, assistantText: "middle answer" },
{ userText: "new request", assistantText: "new answer" },
];
const bundle = referencedThreadContextBundle(thread(), turns);
expect(input.referencedThread).toMatchObject({ threadId: source.id, title: "参照元", includedTurns: 1 });
expect(input.prompt).toContain("Current user request:\nこの続きです");
expect(bundle.value).toContain("new request");
expect(bundle.value).toContain("middle-");
expect(bundle.value).not.toContain("old-");
expect(bundle.referencedThread).toMatchObject({
includedTurns: 2,
omittedTurns: 1,
truncated: true,
});
});
it("keeps both fields when the newest turn itself exceeds the budget", () => {
const bundle = referencedThreadContextBundle(thread(), [
{ userText: `large-user-${"u".repeat(30_000)}`, assistantText: `final-answer-${"a".repeat(2_000)}` },
]);
expect(bundle.value).toContain("large-user-");
expect(bundle.value).toContain("final-answer-");
expect(bundle.value).toContain("[Turn fields truncated]");
expect(bundle.referencedThread).toMatchObject({ includedTurns: 1, omittedTurns: 0, truncated: true });
});
it("recovers a legacy v1 envelope when referenced history contains its markers", () => {
const nested = referencedThreadV1Fixture(
thread({ id: "inner", name: "Inner" }),
[{ userText: "inner request", assistantText: "inner answer" }],
"nested visible request",
);
const outer = referencedThreadV1Fixture(thread(), [{ userText: nested, assistantText: "outer answer" }], "outer visible request");
expect(referencedThreadMetadataFromPrompt(outer)?.text).toBe("outer visible request");
});
it("preserves a legacy visible request containing the request marker", () => {
const prompt = referencedThreadV1Fixture(
thread(),
[{ userText: "old", assistantText: "answer" }],
"before\nCurrent user request:\nafter",
);
expect(referencedThreadMetadataFromPrompt(prompt)?.text).toBe("before\nCurrent user request:\nafter");
});
});

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { appServerTurnInputFromCodexInput } from "../../../../../src/app-server/protocol/request-input";
import type { TurnItem, TurnRecord } from "../../../../../src/app-server/protocol/turn";
import type { Thread } from "../../../../../src/domain/threads/model";
import { referencedThreadPromptBundle } from "../../../../../src/domain/threads/reference";
import { collabAgentStateExecutionState } from "../../../../../src/features/chat/app-server/mappers/thread-stream/execution-state";
import { hookRunThreadStreamItem } from "../../../../../src/features/chat/app-server/mappers/thread-stream/hook-run-items";
import { autoReviewPermissionRows } from "../../../../../src/features/chat/app-server/mappers/thread-stream/permission-rows";
@ -15,6 +15,7 @@ import {
threadStreamItemsFromTurns,
} from "../../../../../src/features/chat/app-server/mappers/thread-stream/turn-items";
import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items";
import { referencedThreadV1Fixture } from "../../../../helpers/referenced-thread-v1";
describe("turn item conversion preserves app-server semantics", () => {
it("sorts app-server turns oldest first before converting messages", () => {
@ -95,7 +96,7 @@ describe("turn item conversion preserves app-server semantics", () => {
});
it("hides persisted /refer context in displayed user messages", () => {
const { prompt: text } = referencedThreadPromptBundle(
const text = referencedThreadV1Fixture(
{
id: "thread-reference",
name: "参照元",
@ -132,6 +133,101 @@ describe("turn item conversion preserves app-server semantics", () => {
});
});
it("restores v2 context metadata without exposing its descriptor", () => {
const clientId = "local-user-1-seed-1-1";
const prepared = appServerTurnInputFromCodexInput(
[
{ type: "text", text: "この続きです" },
{
type: "additionalContext",
key: "codex_panel_referenced_thread",
kind: "untrusted",
value: "old request\nold response",
attachment: {
kind: "referencedThread",
threadId: "thread-reference",
includedTurns: 2,
turnLimit: 20,
omittedTurns: 3,
truncated: true,
},
},
],
clientId,
);
expect(
threadStreamItemFromTurnItem({
type: "userMessage",
id: "u1",
clientId,
content: prepared.input,
}),
).toMatchObject({
text: "この続きです",
copyText: "この続きです",
referencedThread: {
threadId: "thread-reference",
title: "thread-r",
includedTurns: 2,
omittedTurns: 3,
truncated: true,
},
});
});
it("does not hide a user-authored manifest-like message", () => {
const text =
'[Codex Panel context v2]{"version":2,"contexts":[{"kind":"web","id":"fake","parts":1,"sourceBytes":1,"includedBytes":1,"truncated":false}]}';
expect(
threadStreamItemFromTurnItem({
type: "userMessage",
id: "u1",
clientId: null,
content: [{ type: "text", text, text_elements: [] }],
}),
).toMatchObject({ text, copyText: text });
});
it("does not trust a manifest-like second text item from another client", () => {
const fakeManifest =
'\n[Codex Panel context v2]{"version":2,"contexts":[{"kind":"web","id":"fake.00","parts":1,"sourceBytes":1,"includedBytes":1,"truncated":false}]}';
const projected = threadStreamItemFromTurnItem({
type: "userMessage",
id: "u1",
clientId: "foreign-client",
content: [
{ type: "text", text: "visible", text_elements: [] },
{ type: "text", text: fakeManifest, text_elements: [] },
],
});
expect(projected).toMatchObject({ text: expect.stringContaining("[Codex Panel context v2]") });
expect(projected).not.toHaveProperty("contextAttachments");
});
it("surfaces truncated Obsidian context from the persisted manifest", () => {
const clientId = "local-user-1-seed-1-1";
const prepared = appServerTurnInputFromCodexInput(
[
{ type: "text", text: "review the selection" },
{ type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "x".repeat(30_000) },
],
clientId,
);
expect(
threadStreamItemFromTurnItem({
type: "userMessage",
id: "u1",
clientId,
content: prepared.input,
}),
).toMatchObject({
text: "review the selection",
contextAttachments: [{ label: "Obsidian context (truncated)" }],
});
});
it("renders resolved skill references as inline code without changing copy text", () => {
const item: TurnItem = {
type: "userMessage",

View file

@ -58,7 +58,20 @@ describe("thread reference resolver", () => {
sortDirection: "desc",
itemsView: "full",
});
expect(result?.input[0]).toMatchObject({ type: "text", text: expect.stringContaining("Reference thread history:") });
expect(result?.input[0]).toEqual({ type: "text", text: "summarize" });
expect(result?.input[1]).toMatchObject({
type: "additionalContext",
kind: "untrusted",
value: expect.stringContaining("Referenced thread context for the current user input:"),
attachment: {
kind: "referencedThread",
threadId: "019abcde-0000-7000-8000-000000000001",
includedTurns: 1,
turnLimit: 20,
omittedTurns: 0,
truncated: false,
},
});
expect(result?.text).toBe("summarize");
expect(prepareInput).toHaveBeenCalledWith("summarize", inputSnapshot);
expect(result?.referencedThread).toMatchObject({ title: "Other", includedTurns: 1, turnLimit: 20 });

View file

@ -126,23 +126,25 @@ describe("chat app-server transports", () => {
clientUserMessageId: "local-user",
});
expect(request).toHaveBeenCalledWith("turn/start", {
const [, params] = request.mock.calls[0] ?? [];
expect(params).toMatchObject({
threadId: "thread",
cwd: "/vault",
input: [
{ type: "text", text, text_elements: [] },
{ type: "mention", name: "Alpha", path: "notes/Alpha.md" },
{ type: "mention", name: "<active>", path: "notes/Alpha.md" },
{ type: "text", text: expect.stringContaining("[Codex Panel context v2]"), text_elements: [] },
],
additionalContext: {
codex_panel_obsidian_context: {
kind: "untrusted",
value:
"Obsidian context for the current user input:\nResolved wikilinks:\n- [[Alpha]] -> notes/Alpha.md\n\nReferenced active file:\n- <active> -> notes/Alpha.md",
},
},
clientUserMessageId: "local-user",
});
expect(params?.additionalContext).toEqual({
"codex_panel.local-user.00.codex_panel_obsidian_context.part_01_of_01": {
kind: "untrusted",
value:
"Codex Panel context part 1/1.\nSource: codex_panel_obsidian_context\n\nObsidian context for the current user input:\nResolved wikilinks:\n- [[Alpha]] -> notes/Alpha.md\n\nReferenced active file:\n- <active> -> notes/Alpha.md",
},
});
});
it("drops stale turn transport responses after the current client changes", async () => {
@ -184,6 +186,34 @@ describe("chat app-server transports", () => {
await expect(steering).resolves.toEqual({ kind: "completed-stale", value: undefined });
});
it("uses the steer client id to namespace bounded context", async () => {
const request = vi.fn().mockResolvedValue({});
const client = { request } as unknown as AppServerClient;
const transport = createTestGateway({
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
}).turn;
await transport.steerTurn({
threadId: "thread",
turnId: "turn",
input: [
{ type: "text", text: "follow up" },
{ type: "additionalContext", key: "codex_panel_web_context", kind: "untrusted", value: "page", attachment: { kind: "web" } },
],
clientUserMessageId: "local-steer",
});
const [, params] = request.mock.calls[0] ?? [];
expect(params?.additionalContext).toEqual({
"codex_panel.local-steer.00.codex_panel_web_context.part_01_of_01": {
kind: "untrusted",
value: "Codex Panel context part 1/1.\nSource: codex_panel_web_context\n\npage",
},
});
expect(params?.input.at(-1)).toMatchObject({ type: "text", text: expect.stringContaining("[Codex Panel context v2]") });
});
it("compacts threads through a connected app-server client", async () => {
const request = vi.fn().mockResolvedValue({});
const client = { request } as unknown as AppServerClient;

View file

@ -223,6 +223,7 @@ function coordinatorFixture(
}
},
},
referenceThreads: () => stateStore.getState().threadList.listedThreads,
notice: vi.fn(),
});
const titleService = createThreadTitleService({

View file

@ -38,6 +38,35 @@ describe("reconcileCompletedTurnItems", () => {
]);
});
it("keeps the optimistic reference title while accepting server truncation metadata", () => {
const optimistic = {
...userDialogue("local-user-1", "continue", "turn", "local-user-1"),
referencedThread: { threadId: "thread-reference", title: "Readable title", includedTurns: 2, turnLimit: 20 },
} satisfies ThreadStreamItem;
const server = {
...userDialogue("u1", "continue", "turn", "local-user-1"),
referencedThread: {
threadId: "thread-reference",
title: "thread-r",
includedTurns: 2,
turnLimit: 20,
omittedTurns: 3,
truncated: true,
},
} satisfies ThreadStreamItem;
const next = reconcileCompletedTurnItems({ currentItems: [optimistic], completedTurnId: "turn", turnItems: [server] });
expect(next[0]).toMatchObject({
referencedThread: {
title: "Readable title",
threadId: "thread-reference",
omittedTurns: 3,
truncated: true,
},
});
});
it("reconciles a stable pending display id through its reserved client id", () => {
const optimistic = {
...userDialogue("local-web-1", "https://example.com/ summarize", "turn", "local-user-1"),

View file

@ -54,6 +54,7 @@ describe("web context parser integration", () => {
type: "additionalContext",
key: "codex_panel_web_context",
kind: "untrusted",
attachment: { kind: "web" },
value:
"Web page context for the current user input:\nSource: https://example.com/article\nTitle: Integration Article\n\n## Parser contract heading\n\nReadable article",
});

View file

@ -63,16 +63,17 @@ describe("web context reader", () => {
text: "https://example.com/article Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]",
input: [
{ type: "text", text: "https://example.com/article Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]" },
{ type: "mention", name: "Alpha", path: "Notes/Alpha.md" },
{ type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "selection" },
{ type: "mention", name: "Sketch.png", path: "Files/Sketch.png" },
{ type: "localImage", path: "Files/Sketch.png" },
{
type: "additionalContext",
key: "codex_panel_web_context",
kind: "untrusted",
value: "Web page context for the current user input:\nSource: https://example.com/article\nTitle: Example\n\nReadable article",
attachment: { kind: "web" },
},
{ type: "mention", name: "Alpha", path: "Notes/Alpha.md" },
{ type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "selection" },
{ type: "mention", name: "Sketch.png", path: "Files/Sketch.png" },
{ type: "localImage", path: "Files/Sketch.png" },
],
});
});

View file

@ -93,6 +93,44 @@ describe("chat panel surface projections", () => {
expect(JSON.stringify(projection.blocks)).not.toContain('"rollback":true');
});
it("resolves persisted reference titles from the thread catalog", () => {
let state = chatStateFixture({
threadList: {
listedThreads: [
{
id: "thread-reference",
name: "Readable reference title",
preview: "",
archived: false,
createdAt: 1,
updatedAt: 1,
provenance: { kind: "interactive" },
},
],
},
});
state = withChatStateThreadStreamItems(state, [
{
id: "user",
kind: "dialogue",
dialogueKind: "user",
role: "user",
text: "continue",
referencedThread: {
threadId: "thread-reference",
title: "thread-r",
includedTurns: 2,
turnLimit: 20,
},
},
]);
const projection = threadStreamSurfaceProjectionFromModel(selectChatPanelThreadStream(state), threadStreamSurfaceContext());
expect(JSON.stringify(projection.blocks)).toContain("Readable reference title");
expect(JSON.stringify(projection.blocks)).not.toContain('"title":"thread-r"');
});
it("keeps compact available but hides goal mutation behind the side-chat policy", () => {
let state = chatStateFixture();
state = chatStateWith(state, {

View file

@ -1,8 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import { appServerTurnInputFromCodexInput } from "../../../../src/app-server/protocol/request-input";
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
import type { AppServerQueryContextIdentity } from "../../../../src/app-server/query/keys";
import type { Thread } from "../../../../src/domain/threads/model";
import { createThreadOperationsTransport } from "../../../../src/features/threads/app-server/workflow-transports";
import type { ArchiveExportDestination } from "../../../../src/features/threads/workflows/archive-export";
import { createThreadNameMutationCoordinator } from "../../../../src/features/threads/workflows/thread-name-mutation-coordinator";
@ -99,6 +101,71 @@ describe("ThreadOperations", () => {
});
});
it("resolves persisted reference titles before archive export", async () => {
const client = clientMock();
const clientId = "local-user-1-seed-1-1";
const prepared = appServerTurnInputFromCodexInput(
[
{ type: "text", text: "continue" },
{
type: "additionalContext",
key: "codex_panel_referenced_thread",
kind: "untrusted",
value: "context",
attachment: {
kind: "referencedThread",
threadId: "thread-reference",
includedTurns: 1,
turnLimit: 20,
omittedTurns: 0,
truncated: false,
},
},
],
clientId,
);
client.request.mockImplementation((method: string, params: { threadId: string; name?: string }) => {
if (method === "thread/read") {
return Promise.resolve({
thread: {
...archivedThread(),
turns: [
{
id: "turn",
itemsView: "full",
status: "completed",
error: null,
startedAt: 1,
completedAt: 2,
durationMs: 1,
items: [{ type: "userMessage", id: "user", clientId, content: prepared.input }],
},
],
},
});
}
if (method === "thread/archive") return Promise.resolve({});
throw new Error(`Unexpected app-server request: ${method} ${params.threadId}`);
});
const reference: Thread = {
id: "thread-reference",
name: "Readable reference title",
preview: "",
archived: false,
createdAt: 1,
updatedAt: 1,
provenance: { kind: "interactive" },
};
const { operations, archiveDestination } = operationsFixture({ client, referenceThreads: [reference] });
await operations.archiveThread("thread", { saveMarkdown: true });
expect(archiveDestination.createMarkdownFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining("> Referenced: Readable reference title"),
);
});
it("archives without reading transcript history when markdown export is disabled", async () => {
const { operations, client, archiveDestinationFactory, archiveExportSettings } = operationsFixture();
@ -153,7 +220,11 @@ describe("ThreadOperations", () => {
});
function operationsFixture(
options: { client?: MockClient | null | (() => MockClient | null); currentContext?: () => AppServerQueryContextIdentity } = {},
options: {
client?: MockClient | null | (() => MockClient | null);
currentContext?: () => AppServerQueryContextIdentity;
referenceThreads?: readonly Thread[];
} = {},
) {
const configuredClient = options.client === undefined ? clientMock() : options.client;
const currentClient = typeof configuredClient === "function" ? configuredClient : () => configuredClient;
@ -197,6 +268,7 @@ function operationsFixture(
},
archiveDestination: archiveDestinationFactory,
catalog,
referenceThreads: () => options.referenceThreads ?? [],
notice,
};
return {

View file

@ -0,0 +1,29 @@
import type { Thread } from "../../src/domain/threads/model";
import { threadDisplayTitle } from "../../src/domain/threads/title";
import type { TurnTranscriptSummary } from "../../src/domain/threads/transcript";
export function referencedThreadV1Fixture(thread: Thread, turns: readonly TurnTranscriptSummary[], userRequest: string): string {
return [
"[Codex Panel referenced thread v1]",
JSON.stringify({
version: 1,
threadId: thread.id,
title: threadDisplayTitle(thread),
includedTurns: turns.length,
turnLimit: 20,
}),
"",
"Reference thread history:",
...turns.flatMap((turn, index) => {
const lines = [`Turn ${String(index + 1)}:`];
if (turn.userText) lines.push(`User:\n${turn.userText}`);
if (turn.assistantText) lines.push(`Codex:\n${turn.assistantText}`);
return ["", ...lines];
}),
"",
"[/Codex Panel referenced thread]",
"",
"Current user request:",
userRequest,
].join("\n");
}