diff --git a/README.md b/README.md index 10ee8f48..ff5abadf 100644 --- a/README.md +++ b/README.md @@ -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 [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 diff --git a/docs/design.md b/docs/design.md index caa36b7d..558d1527 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 diff --git a/src/app-server/protocol/request-input.ts b/src/app-server/protocol/request-input.ts index 77696cd4..c4314109 100644 --- a/src/app-server/protocol/request-input.ts +++ b/src/app-server/protocol/request-input.ts @@ -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; +} + +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 | undefined { + return appServerTurnInputFromCodexInput(input, submissionId).additionalContext; +} + +export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[], submissionId: string): AppServerTurnInput { const additionalContext: Record = {}; - 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 => + 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[]): 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, + 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["detail"]): { detail?: AppServerUserInputImageDetail; } { diff --git a/src/app-server/protocol/turn.ts b/src/app-server/protocol/turn.ts index 5826f2df..949a6adc 100644 --- a/src/app-server/protocol/turn.ts +++ b/src/app-server/protocol/turn.ts @@ -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["content"][number]; -type AppServerTextUserInput = Extract; - 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): string { - return userInputText(item.content); +export interface TurnUserItemProjection { + text: string; + referencedThread: ReferencedThreadMetadata | null; + manifest: ReturnType["manifest"]; +} + +export function turnUserItemProjection(item: Extract): 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["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"; -} diff --git a/src/app-server/services/turns.ts b/src/app-server/services/turns.ts index 03b3aea7..f1c9dc2d 100644 --- a/src/app-server/services/turns.ts +++ b/src/app-server/services/turns.ts @@ -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; @@ -32,14 +32,14 @@ export function startTurn( options: AppServerStartTurnOptions, ): Promise { 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 { - 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`; +} diff --git a/src/domain/chat/context-budget.ts b/src/domain/chat/context-budget.ts new file mode 100644 index 00000000..fe294778 --- /dev/null +++ b/src/domain/chat/context-budget.ts @@ -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; +} diff --git a/src/domain/chat/context-manifest.ts b/src/domain/chat/context-manifest.ts new file mode 100644 index 00000000..ffb9efde --- /dev/null +++ b/src/domain/chat/context-manifest.ts @@ -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; + 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; + 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; +} diff --git a/src/domain/chat/input.ts b/src/domain/chat/input.ts index f036922b..de27fd94 100644 --- a/src/domain/chat/input.ts +++ b/src/domain/chat/input.ts @@ -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"; diff --git a/src/domain/threads/archive-markdown.ts b/src/domain/threads/archive-markdown.ts index 8b9d6d21..f38bf2e2 100644 --- a/src/domain/threads/archive-markdown.ts +++ b/src/domain/threads/archive-markdown.ts @@ -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[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}`; diff --git a/src/domain/threads/reference.ts b/src/domain/threads/reference.ts index c8995a58..fe7a759a 100644 --- a/src/domain/threads/reference.ts +++ b/src/domain/threads/reference.ts @@ -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, diff --git a/src/domain/threads/transcript.ts b/src/domain/threads/transcript.ts index b54940d5..36df30bb 100644 --- a/src/domain/threads/transcript.ts +++ b/src/domain/threads/transcript.ts @@ -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"; diff --git a/src/features/chat/app-server/mappers/thread-stream/turn-items.ts b/src/features/chat/app-server/mappers/thread-stream/turn-items.ts index 06a1ba12..2c5ac9f2 100644 --- a/src/features/chat/app-server/mappers/thread-stream/turn-items.ts +++ b/src/features/chat/app-server/mappers/thread-stream/turn-items.ts @@ -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 } : {}), }; } diff --git a/src/features/chat/app-server/thread-reference-resolver.ts b/src/features/chat/app-server/thread-reference-resolver.ts index 7f58ebd4..1ad2d98e 100644 --- a/src/features/chat/app-server/thread-reference-resolver.ts +++ b/src/features/chat/app-server/thread-reference-resolver.ts @@ -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) { diff --git a/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts b/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts index badc3f22..6fb685f2 100644 --- a/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts +++ b/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts @@ -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); diff --git a/src/features/chat/domain/thread-stream/format/context-attachments.ts b/src/features/chat/domain/thread-stream/format/context-attachments.ts index 5f83bddd..56545bad 100644 --- a/src/features/chat/domain/thread-stream/format/context-attachments.ts +++ b/src/features/chat/domain/thread-stream/format/context-attachments.ts @@ -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; + } +} diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index 008713db..b69b7a9e 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -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); }, diff --git a/src/features/chat/host/obsidian/web-context.obsidian.ts b/src/features/chat/host/obsidian/web-context.obsidian.ts index b5dc8aac..6bf4a095 100644 --- a/src/features/chat/host/obsidian/web-context.obsidian.ts +++ b/src/features/chat/host/obsidian/web-context.obsidian.ts @@ -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 }, ]), }; } diff --git a/src/features/chat/panel/shell-selectors.ts b/src/features/chat/panel/shell-selectors.ts index b5a28574..9480bdf2 100644 --- a/src/features/chat/panel/shell-selectors.ts +++ b/src/features/chat/panel/shell-selectors.ts @@ -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", diff --git a/src/features/chat/panel/surface/thread-stream-projection.ts b/src/features/chat/panel/surface/thread-stream-projection.ts index a2ce3374..d34c5d5d 100644 --- a/src/features/chat/panel/surface/thread-stream-projection.ts +++ b/src/features/chat/panel/surface/thread-stream-projection.ts @@ -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"], diff --git a/src/features/chat/presentation/thread-stream/text-view.ts b/src/features/chat/presentation/thread-stream/text-view.ts index 0d62bf76..599fd681 100644 --- a/src/features/chat/presentation/thread-stream/text-view.ts +++ b/src/features/chat/presentation/thread-stream/text-view.ts @@ -22,6 +22,7 @@ export interface ReferencedThreadTextView { title: string; includedTurns: number; turnLimit: number; + truncated?: boolean; } export interface ContextItemTextView { diff --git a/src/features/chat/ui/thread-stream/text.tsx b/src/features/chat/ui/thread-stream/text.tsx index 8a02f644..9316454d 100644 --- a/src/features/chat/ui/thread-stream/text.tsx +++ b/src/features/chat/ui/thread-stream/text.tsx @@ -138,6 +138,7 @@ function ReferencedThread({ reference }: { reference: ReferencedThreadTextView } · {reference.includedTurns}/{reference.turnLimit} turns + {reference.truncated ? " · truncated" : ""} diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 09e7372f..3b6f750a 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -109,6 +109,7 @@ export class ThreadsViewSession { }, archiveDestination: () => this.environment.archiveDestination(), catalog: this.host.threadCatalog, + referenceThreads: () => this.threads, notice: (message) => { new Notice(message); }, diff --git a/src/features/threads/workflows/thread-operations.ts b/src/features/threads/workflows/thread-operations.ts index c97c4f2f..9b803797 100644 --- a/src/features/threads/workflows/thread-operations.ts +++ b/src/features/threads/workflows/thread-operations.ts @@ -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, diff --git a/tests/app-server/request-input.test.ts b/tests/app-server/request-input.test.ts index 3e618480..16c0f4c5 100644 --- a/tests/app-server/request-input.test.ts +++ b/tests/app-server/request-input.test.ts @@ -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]); + }); }); diff --git a/tests/domain/threads/archive-markdown.test.ts b/tests/domain/threads/archive-markdown.test.ts index ca5d0477..0ff7d1c2 100644 --- a/tests/domain/threads/archive-markdown.test.ts +++ b/tests/domain/threads/archive-markdown.test.ts @@ -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}}', diff --git a/tests/domain/threads/reference.test.ts b/tests/domain/threads/reference.test.ts index 7d236b73..bbfa1e54 100644 --- a/tests/domain/threads/reference.test.ts +++ b/tests/domain/threads/reference.test.ts @@ -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 { return { @@ -19,7 +20,7 @@ function thread(overrides: Partial = {}): 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"); }); }); diff --git a/tests/features/chat/app-server/mappers/thread-stream.test.ts b/tests/features/chat/app-server/mappers/thread-stream.test.ts index c9cb1a33..0930275e 100644 --- a/tests/features/chat/app-server/mappers/thread-stream.test.ts +++ b/tests/features/chat/app-server/mappers/thread-stream.test.ts @@ -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", diff --git a/tests/features/chat/app-server/thread-reference-resolver.test.ts b/tests/features/chat/app-server/thread-reference-resolver.test.ts index e1295909..e0895c62 100644 --- a/tests/features/chat/app-server/thread-reference-resolver.test.ts +++ b/tests/features/chat/app-server/thread-reference-resolver.test.ts @@ -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 }); diff --git a/tests/features/chat/app-server/transports.test.ts b/tests/features/chat/app-server/transports.test.ts index 28ac7ecf..0eab48ff 100644 --- a/tests/features/chat/app-server/transports.test.ts +++ b/tests/features/chat/app-server/transports.test.ts @@ -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: "", 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- -> 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- -> 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; diff --git a/tests/features/chat/application/threads/auto-title-coordinator.test.ts b/tests/features/chat/application/threads/auto-title-coordinator.test.ts index 0093adc7..5b9169ba 100644 --- a/tests/features/chat/application/threads/auto-title-coordinator.test.ts +++ b/tests/features/chat/application/threads/auto-title-coordinator.test.ts @@ -223,6 +223,7 @@ function coordinatorFixture( } }, }, + referenceThreads: () => stateStore.getState().threadList.listedThreads, notice: vi.fn(), }); const titleService = createThreadTitleService({ diff --git a/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts b/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts index 360bca32..60baf396 100644 --- a/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts +++ b/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts @@ -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"), diff --git a/tests/features/chat/host/web-context.integration.test.ts b/tests/features/chat/host/web-context.integration.test.ts index 82071133..fdd0b9ce 100644 --- a/tests/features/chat/host/web-context.integration.test.ts +++ b/tests/features/chat/host/web-context.integration.test.ts @@ -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", }); diff --git a/tests/features/chat/host/web-context.test.ts b/tests/features/chat/host/web-context.test.ts index b3479d16..ff760d3a 100644 --- a/tests/features/chat/host/web-context.test.ts +++ b/tests/features/chat/host/web-context.test.ts @@ -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" }, ], }); }); diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index 7f0a3607..30a33612 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -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, { diff --git a/tests/features/threads/workflows/thread-operations.test.ts b/tests/features/threads/workflows/thread-operations.test.ts index a19d5923..4f4888ce 100644 --- a/tests/features/threads/workflows/thread-operations.test.ts +++ b/tests/features/threads/workflows/thread-operations.test.ts @@ -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 { diff --git a/tests/helpers/referenced-thread-v1.ts b/tests/helpers/referenced-thread-v1.ts new file mode 100644 index 00000000..3ef874cd --- /dev/null +++ b/tests/helpers/referenced-thread-v1.ts @@ -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"); +}