Add referenced thread slash command

This commit is contained in:
murashit 2026-05-18 16:24:04 +09:00
parent 49ce4b141f
commit eb5dfd0340
15 changed files with 446 additions and 8 deletions

View file

@ -40,7 +40,7 @@ Use `Codex Panel: New chat` to start a fresh thread. Use `Codex Panel: Open new
Codex Panel supports the Codex workflows that are useful from a side panel:
- Start, resume, rename, fork, roll back, compact, and archive threads (`/new`, `/resume`, `/fork`, `/rollback`, `/compact`).
- Start, resume, rename, fork, roll back, compact, archive, and reference threads (`/new`, `/resume`, `/refer`, `/fork`, `/rollback`, `/compact`).
- Complete slash commands (`/help`) and enabled Codex skills in the composer.
- Send steering messages during a running turn, or interrupt when the composer is empty.
- Toggle Plan mode, fast mode, model override, and reasoning effort override for subsequent turns (`/plan`, `/fast`, `/model`, `/effort`).
@ -57,6 +57,7 @@ Codex Panel supports the Codex workflows that are useful from a side panel:
Codex Panel makes a few Obsidian-specific adjustments instead of mirroring the terminal UI exactly:
- Wikilinks in sent messages are resolved to Codex file mentions when the target file exists. The visible message text is preserved, unresolved wikilinks are left alone, and note bodies are not attached automatically.
- `/refer <thread> <message>` sends a message with up to 20 recent turns from another non-archived thread as extra context. It includes user input and final Codex responses only, without intermediate tool calls or command output.
- Markdown links in rendered messages that point to existing vault files open in Obsidian. External links and non-vault file paths keep their normal link behavior.
- Forking a thread opens the fork in a new right-sidebar panel so the source thread stays visible.
- Rolling back is limited to thread history. It restores the rolled-back prompt to the composer, but it does not revert files changed by Codex.

View file

@ -1,6 +1,7 @@
export const SLASH_COMMANDS = [
{ command: "/new", detail: "Start a new Codex thread, optionally sending a message." },
{ command: "/resume", detail: "Resume a recent Codex thread." },
{ command: "/refer", detail: "Send a message with up to 20 recent turns from another non-archived thread." },
{ command: "/fork", detail: "Fork the active Codex thread." },
{ command: "/rollback", detail: "Drop the latest turn and restore its prompt to the composer." },
{ command: "/compact", detail: "Compact the current conversation context." },

View file

@ -44,7 +44,7 @@ export function activeComposerSuggestions(
): ComposerSuggestion[] {
return (
activeWikiLinkSuggestions(beforeCursor, notes) ??
activeThreadResumeSuggestions(beforeCursor, threads) ??
activeThreadCommandSuggestions(beforeCursor, threads) ??
activeModelOverrideSuggestions(beforeCursor, models) ??
activeReasoningEffortSuggestions(beforeCursor, models, currentModel) ??
activeSlashCommandSuggestions(beforeCursor) ??
@ -145,8 +145,8 @@ export function activeSlashCommandSuggestions(beforeCursor: string): ComposerSug
}));
}
export function activeThreadResumeSuggestions(beforeCursor: string, threads: Thread[]): ComposerSuggestion[] | null {
const match = beforeCursor.match(/(?:^|\n)\/resume\s+([^\n]{0,120})$/);
export function activeThreadCommandSuggestions(beforeCursor: string, threads: Thread[]): ComposerSuggestion[] | null {
const match = beforeCursor.match(/(?:^|\n)\/(?:resume|refer)\s+([^\s\n]{0,120})$/);
if (!match || match.index === undefined) return null;
const rawQuery = match[1] ?? "";

View file

@ -2,6 +2,7 @@ import type { DisplayDetailSection, DisplayFileChange, DisplayItem } from "./typ
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
import type { Turn } from "../generated/app-server/v2/Turn";
import { inputToText, truncate } from "../utils";
import { referencedThreadDisplayFromPrompt } from "../threads/reference";
import { agentDisplayItem } from "./agent";
import { pathRelativeToRoot } from "./paths";
import { normalizeProposedPlanMarkdown } from "./plan";
@ -86,6 +87,20 @@ export function displayItemFromThreadItem(item: ThreadItem, turnId?: string): Di
function userMessageDisplayItem(item: UserMessageItem, turnId?: string): DisplayItem {
const text = inputToText(item.content);
const referencedThread = referencedThreadDisplayFromPrompt(text);
if (referencedThread) {
return {
id: item.id,
kind: "message",
role: "user",
text: referencedThread.text,
copyText: referencedThread.text,
referencedThread: referencedThread.reference,
turnId,
itemId: item.id,
markdown: true,
};
}
return {
id: item.id,
kind: "message",

View file

@ -39,6 +39,7 @@ export interface MessageDisplayItem extends DisplayBase {
kind: "message";
role: "user" | "assistant";
copyText?: string;
referencedThread?: ReferencedThreadDisplay;
proposedPlan?: boolean;
editedFiles?: string[];
turnDiff?: DisplayTurnDiff;
@ -46,6 +47,13 @@ export interface MessageDisplayItem extends DisplayBase {
markdown?: boolean;
}
export interface ReferencedThreadDisplay {
threadId: string;
title: string;
includedTurns: number;
turnLimit: number;
}
export interface DisplayTurnDiff {
diff: string;
}

View file

@ -1,5 +1,6 @@
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
import type { Thread } from "../generated/app-server/v2/Thread";
import type { UserInput } from "../generated/app-server/v2/UserInput";
import { getThreadTitle } from "../threads/model";
import { slashCommandHelpLines, type SlashCommandName } from "../composer/slash-commands";
import {
@ -15,6 +16,7 @@ export interface SlashCommandExecutionContext {
listedThreads: Thread[];
startNewThread: () => Promise<void>;
resumeThread: (threadId: string) => Promise<void>;
referThread: (thread: Thread, message: string) => Promise<UserInput[] | null>;
forkThread: (threadId: string) => Promise<void>;
rollbackThread: (threadId: string) => Promise<void>;
compactThread: (threadId: string) => Promise<void>;
@ -32,6 +34,7 @@ export interface SlashCommandExecutionContext {
export interface SlashCommandExecutionResult {
sendText?: string;
sendInput?: UserInput[];
}
export async function executeSlashCommand(
@ -55,6 +58,26 @@ export async function executeSlashCommand(
return;
}
if (command === "refer") {
const parsed = parseReferArgs(args);
if (!parsed) {
context.addSystemMessage("Usage: /refer <thread> <message>");
return;
}
const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
}
if (thread.thread.id === context.activeThreadId) {
context.addSystemMessage("Use the current thread directly instead of referencing it.");
return;
}
const input = await context.referThread(thread.thread, parsed.message);
if (!input) return;
return { sendText: parsed.message, sendInput: input };
}
if (command === "fork") {
if (args) {
context.addSystemMessage(`Unsupported slash command arguments: ${args}`);
@ -159,6 +182,12 @@ export async function executeSlashCommand(
type ThreadResolution = { ok: true; thread: Thread } | { ok: false; message: string };
function parseReferArgs(args: string): { threadQuery: string; message: string } | null {
const match = args.match(/^(\S+)\s+([\s\S]*\S)\s*$/);
if (!match) return null;
return { threadQuery: match[1], message: match[2] };
}
export function resolveThreadArgument(args: string, threads: Thread[]): ThreadResolution {
const query = args.trim();
if (!query) {

View file

@ -10,6 +10,8 @@ import { VIEW_TYPE_CODEX_PANEL } from "../constants";
import { createSystemItem } from "../display/system";
import type { DisplayItem } from "../display/types";
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
import type { Thread } from "../generated/app-server/v2/Thread";
import type { UserInput } from "../generated/app-server/v2/UserInput";
import {
collaborationModeLabel as formatCollaborationModeLabel,
collaborationModeToggleMessage,
@ -48,6 +50,7 @@ import { questionDefaultAnswer, type PendingUserInput } from "../user-input/mode
import { PanelComposerController } from "./composer-controller";
import { clearActiveThreadState, clearConnectionScopedState, createPanelState, type PanelState } from "./state";
import { codexPanelDisplayTitle, getThreadTitle, inheritedForkThreadName, upsertThread } from "../threads/model";
import { referencedThreadPrompt, referencedThreadStatus, referencedThreadTurns, REFERENCED_THREAD_TURN_LIMIT } from "../threads/reference";
import { renderPendingRequestMessage } from "../ui/pending-request-message";
import { renderToolbar, toolbarSignature, type ToolbarChoice, type ToolbarViewModel } from "../ui/toolbar";
import type { TurnDiffViewState } from "../ui/turn-diff";
@ -354,7 +357,7 @@ export class CodexPanelView extends ItemView {
this.composerController.setDraft("", { clearSuggestions: true });
const result = await this.executeSlashCommand(slashCommand.command, slashCommand.args);
if (result?.sendText) {
await this.sendTurnText(result.sendText);
await this.sendTurnText(result.sendText, result.sendInput);
}
this.render();
return;
@ -363,7 +366,7 @@ export class CodexPanelView extends ItemView {
await this.sendTurnText(text);
}
private async sendTurnText(text: string): Promise<void> {
private async sendTurnText(text: string, codexInputOverride?: UserInput[]): Promise<void> {
const client = this.client;
if (!client) return;
@ -399,7 +402,7 @@ export class CodexPanelView extends ItemView {
if (turnSettings.warning) {
this.addSystemMessage(`${this.collaborationModeLabel()} mode is selected, but ${turnSettings.warning}`);
}
const codexInput = this.composerController.codexInput(text);
const codexInput = codexInputOverride ?? this.composerController.codexInput(text);
const activeThreadId = this.state.activeThreadId;
if (!activeThreadId) return;
const response = await client.startTurn(
@ -496,6 +499,7 @@ export class CodexPanelView extends ItemView {
listedThreads: this.state.listedThreads,
startNewThread: () => this.startNewThread(),
resumeThread: (threadId) => this.resumeThread(threadId),
referThread: (thread, message) => this.referencedThreadInput(thread, message),
forkThread: (threadId) => this.forkThread(threadId),
rollbackThread: (threadId) => this.rollbackThread(threadId),
compactThread: async (threadId) => {
@ -515,6 +519,25 @@ export class CodexPanelView extends ItemView {
});
}
private async referencedThreadInput(thread: Thread, message: string): Promise<UserInput[] | null> {
if (!this.client) return null;
try {
const response = await this.client.threadTurnsList(thread.id, null, REFERENCED_THREAD_TURN_LIMIT);
const turns = referencedThreadTurns(response.data);
if (turns.length === 0) {
this.addSystemMessage("Referenced thread has no readable conversation turns.");
return null;
}
const prompt = referencedThreadPrompt(thread, turns, message);
const messageInput = this.composerController.codexInput(message);
this.setStatus(referencedThreadStatus(thread, turns.length));
return [{ type: "text", text: prompt, text_elements: [] }, ...messageInput.filter((item) => item.type !== "text")];
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
return null;
}
}
private toggleFastMode(): void {
const current = currentServiceTier(this.runtimeSnapshot(), configRecord(this.state.effectiveConfig));
const next: ServiceTier = current === "fast" ? "standard" : "fast";

111
src/threads/reference.ts Normal file
View file

@ -0,0 +1,111 @@
import type { Thread } from "../generated/app-server/v2/Thread";
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
import type { Turn } from "../generated/app-server/v2/Turn";
import { inputToText, shortThreadId } from "../utils";
import { getThreadTitle } from "./model";
import type { ReferencedThreadDisplay } from "../display/types";
export const REFERENCED_THREAD_TURN_LIMIT = 20;
export interface ReferencedThreadTurn {
userText: string | null;
assistantText: string | null;
}
export function referencedThreadTurns(turns: Turn[]): ReferencedThreadTurn[] {
return [...turns]
.sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0))
.map((turn) => ({
userText: firstUserMessage(turn.items),
assistantText: lastAssistantMessage(turn.items),
}))
.filter((turn) => turn.userText || turn.assistantText);
}
export function referencedThreadPrompt(thread: Thread, turns: ReferencedThreadTurn[], userRequest: string): string {
const title = getThreadTitle(thread);
const heading = [
"[Codex Panel referenced thread]",
`Title: ${title}`,
`Thread ID: ${thread.id}`,
`Included turns: ${turns.length}/${REFERENCED_THREAD_TURN_LIMIT}`,
"Included history: user input and final Codex responses only.",
];
return [
...heading,
"",
"Reference conversation:",
...turns.flatMap((turn, index) => {
const lines = [`Turn ${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");
}
export function referencedThreadStatus(thread: Thread, count: number): string {
return `Referencing ${shortThreadId(thread.id)} (${count}/${REFERENCED_THREAD_TURN_LIMIT} turns).`;
}
export function referencedThreadDisplayFromPrompt(text: string): { text: string; reference: ReferencedThreadDisplay } | null {
const headerStart = text.indexOf("[Codex Panel referenced thread]");
const headerEnd = text.indexOf("[/Codex Panel referenced thread]");
const requestMarker = "\nCurrent user request:\n";
const requestStart = text.indexOf(requestMarker);
if (headerStart !== 0 || headerEnd === -1 || requestStart === -1 || requestStart < headerEnd) return null;
const header = text.slice(headerStart, headerEnd);
const title = lineValue(header, "Title") ?? "Referenced thread";
const threadId = lineValue(header, "Thread ID") ?? "";
const included = lineValue(header, "Included turns") ?? "";
const turnsMatch = included.match(/^(\d+)\/(\d+)$/);
const includedTurns = turnsMatch ? Number.parseInt(turnsMatch[1], 10) : REFERENCED_THREAD_TURN_LIMIT;
const turnLimit = turnsMatch ? Number.parseInt(turnsMatch[2], 10) : REFERENCED_THREAD_TURN_LIMIT;
const visibleText = text.slice(requestStart + requestMarker.length).trim();
if (!visibleText || !threadId) return null;
return {
text: visibleText,
reference: {
threadId,
title,
includedTurns,
turnLimit,
},
};
}
function lineValue(text: string, key: string): string | null {
const prefix = `${key}:`;
const line = text
.split(/\r?\n/)
.find((candidate) => candidate.startsWith(prefix))
?.slice(prefix.length)
.trim();
return line && line.length > 0 ? line : null;
}
function firstUserMessage(items: ThreadItem[]): string | null {
for (const item of items) {
if (item.type !== "userMessage") continue;
const text = inputToText(item.content).trim();
if (text) return text;
}
return null;
}
function lastAssistantMessage(items: ThreadItem[]): string | null {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item.type !== "agentMessage" && item.type !== "plan") continue;
const text = item.text.trim();
if (text) return text;
}
return null;
}

View file

@ -229,6 +229,9 @@ function renderDisplayItem(parent: HTMLElement, item: DisplayItem, context: Mess
if (item.kind === "message" && item.editedFiles && item.editedFiles.length > 0) {
renderEditedFiles(messageEl, item, context);
}
if (item.kind === "message" && item.referencedThread) {
renderReferencedThread(messageEl, item);
}
if (item.kind === "message" && item.autoReviewSummaries && item.autoReviewSummaries.length > 0) {
renderAutoReviewSummaries(messageEl, item.autoReviewSummaries);
}
@ -247,6 +250,18 @@ function renderMessageAction(parent: HTMLElement, icon: string, label: string, c
return button;
}
function renderReferencedThread(parent: HTMLElement, item: Extract<DisplayItem, { kind: "message" }>): void {
const reference = item.referencedThread;
if (!reference) return;
const wrapper = parent.createDiv({ cls: "codex-panel__referenced-thread" });
const label = wrapper.createSpan({ cls: "codex-panel__referenced-thread-label" });
label.createSpan({ text: "Referenced " });
label.createSpan({ text: reference.title });
label.createSpan({ cls: "codex-panel__edited-files-separator", text: "·" });
label.createSpan({ text: `${reference.includedTurns}/${reference.turnLimit} turns` });
wrapper.title = reference.threadId;
}
function renderEditedFiles(parent: HTMLElement, item: Extract<DisplayItem, { kind: "message" }>, context: MessageStreamContext): void {
const editedFiles = item.editedFiles ?? [];
const label = editedFiles.length === 1 ? "Edited 1 file" : `Edited ${editedFiles.length} files`;

View file

@ -967,12 +967,19 @@
}
.codex-panel__edited-files,
.codex-panel__referenced-thread,
.codex-panel__auto-reviews {
margin-top: 6px;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
.codex-panel__referenced-thread-label {
display: inline-flex;
align-items: center;
gap: var(--codex-panel-control-gap);
}
.codex-panel__edited-files {
display: block;
}

View file

@ -70,6 +70,7 @@ describe("composer suggestions", () => {
expect(parseSlashCommand("/status")).toEqual({ command: "status", args: "" });
expect(parseSlashCommand("/new")).toEqual({ command: "new", args: "" });
expect(parseSlashCommand("/resume thread-1")).toEqual({ command: "resume", args: "thread-1" });
expect(parseSlashCommand("/refer thread-1 続きです")).toEqual({ command: "refer", args: "thread-1 続きです" });
expect(parseSlashCommand("/fork")).toEqual({ command: "fork", args: "" });
expect(parseSlashCommand("/doctor")).toEqual({ command: "doctor", args: "" });
expect(parseSlashCommand("/fast now")).toEqual({ command: "fast", args: "now" });
@ -99,7 +100,7 @@ describe("composer suggestions", () => {
expect(activeComposerSuggestions("/help", notes, [])).toEqual([]);
});
it("suggests recent threads for /resume arguments", () => {
it("suggests recent threads for /resume and /refer arguments", () => {
const threads = [
thread({ id: "019abcde-0000-7000-8000-000000000001", name: "Codex Panel実装" }),
thread({ id: "019abcde-0000-7000-8000-000000000002", name: "別件" }),
@ -121,6 +122,13 @@ describe("composer suggestions", () => {
expect(activeComposerSuggestions("/resume", notes, [], threads)).toEqual([]);
expect(activeComposerSuggestions("/resume 019abcde-0000-7000-8000-000000000001", notes, [], threads)).toEqual([]);
expect(activeComposerSuggestions("/resume 019abcde-0000-7000-8000-000000000001 ", notes, [], threads)).toEqual([]);
expect(activeComposerSuggestions("/refer codex", notes, [], threads)[0]).toMatchObject({
display: "Codex Panel実装",
detail: "019abcde",
replacement: "019abcde-0000-7000-8000-000000000001",
appendSpaceOnInsert: true,
});
expect(activeComposerSuggestions("/refer 019abcde-0000-7000-8000-000000000001 ", notes, [], threads)).toEqual([]);
});
it("does not suggest threads for /fork arguments", () => {

View file

@ -11,6 +11,7 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
listedThreads: [thread({ id: "thread-1", name: "Current" })],
startNewThread: vi.fn().mockResolvedValue(undefined),
resumeThread: vi.fn().mockResolvedValue(undefined),
referThread: vi.fn().mockResolvedValue([{ type: "text", text: "referenced", text_elements: [] }]),
forkThread: vi.fn().mockResolvedValue(undefined),
rollbackThread: vi.fn().mockResolvedValue(undefined),
compactThread: vi.fn().mockResolvedValue(undefined),
@ -102,6 +103,41 @@ describe("slash commands", () => {
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes");
});
it("returns referenced input for /refer", async () => {
const target = thread({ id: "thread-alpha", name: "Alpha" });
const input = [{ type: "text" as const, text: "context\n質問です", text_elements: [] }];
const ctx = context({
listedThreads: [thread({ id: "thread-current", name: "Current" }), target],
referThread: vi.fn().mockResolvedValue(input),
});
const result = await executeSlashCommand("refer", "thread-alpha 質問です", ctx);
expect(ctx.referThread).toHaveBeenCalledWith(target, "質問です");
expect(result).toEqual({ sendText: "質問です", sendInput: input });
});
it("rejects /refer without both thread and message", async () => {
const ctx = context();
await executeSlashCommand("refer", "thread-2", ctx);
expect(ctx.referThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Usage: /refer <thread> <message>");
});
it("rejects /refer for the active thread", async () => {
const ctx = context({
activeThreadId: "thread-1",
listedThreads: [thread({ id: "thread-1", name: "Current" })],
});
await executeSlashCommand("refer", "thread-1 続きです", ctx);
expect(ctx.referThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Use the current thread directly instead of referencing it.");
});
it("forks the active thread for /fork", async () => {
const ctx = context({ activeThreadId: "active-thread" });
@ -193,6 +229,12 @@ describe("slash commands", () => {
);
});
it("documents refer history size", () => {
expect(slashCommandHelpLines().find((line) => line.startsWith("/refer"))).toBe(
"/refer - Send a message with up to 20 recent turns from another non-archived thread.",
);
});
it("documents status and doctor as separate commands", () => {
expect(slashCommandHelpLines().find((line) => line.startsWith("/status"))).toBe(
"/status - Show current session, context, and usage limits.",

View file

@ -58,6 +58,47 @@ describe("thread item conversion preserves app-server semantics", () => {
expect(displayItemFromThreadItem(assistantMessage)).toMatchObject({ role: "assistant", copyText: "world", markdown: true });
});
it("hides persisted /refer context in displayed user messages", () => {
const text = [
"[Codex Panel referenced thread]",
"Title: 参照元",
"Thread ID: thread-reference",
"Included turns: 2/20",
"Included history: user input and final Codex responses only.",
"",
"Reference conversation:",
"",
"Turn 1:",
"User:",
"元の依頼",
"Codex:",
"元の回答",
"",
"[/Codex Panel referenced thread]",
"",
"Current user request:",
"この続きです",
].join("\n");
expect(
displayItemFromThreadItem({
type: "userMessage",
id: "u1",
content: [{ type: "text", text, text_elements: [] }],
}),
).toMatchObject({
role: "user",
text: "この続きです",
copyText: "この続きです",
referencedThread: {
threadId: "thread-reference",
title: "参照元",
includedTurns: 2,
turnLimit: 20,
},
});
});
it("keeps structured skill metadata out of displayed user text when a prompt body exists", () => {
const item: ThreadItem = {
type: "userMessage",

View file

@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import type { Thread } from "../../src/generated/app-server/v2/Thread";
import type { Turn } from "../../src/generated/app-server/v2/Turn";
import { referencedThreadDisplayFromPrompt, referencedThreadPrompt, referencedThreadTurns } from "../../src/threads/reference";
function thread(overrides: Partial<Thread> = {}): Thread {
return {
id: "019abcde-0000-7000-8000-000000000001",
sessionId: "session-1",
forkedFromId: null,
preview: "Preview",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: "idle",
path: null,
cwd: "/vault",
cliVersion: "0.130.0",
source: "appServer",
threadSource: null,
agentNickname: null,
agentRole: null,
gitInfo: null,
name: "参照元",
turns: [],
...overrides,
} as Thread;
}
function turn(id: string, startedAt: number, items: Turn["items"]): Turn {
return {
id,
items,
itemsView: "full",
status: "completed",
error: null,
startedAt,
completedAt: startedAt + 1,
durationMs: 1,
};
}
describe("thread reference context", () => {
it("extracts user messages and final Codex responses in chronological order", () => {
const turns = referencedThreadTurns([
turn("turn-2", 2, [
{ type: "userMessage", id: "u2", content: [{ type: "text", text: "次の依頼", text_elements: [] }] },
{
type: "commandExecution",
id: "cmd",
command: "npm test",
cwd: "/vault",
processId: null,
source: "agent",
status: "completed",
commandActions: [],
aggregatedOutput: "ignored",
exitCode: 0,
durationMs: 1,
},
{ type: "agentMessage", id: "a2", text: "次の回答", phase: "final_answer", memoryCitation: null },
]),
turn("turn-1", 1, [
{ type: "userMessage", id: "u1", content: [{ type: "text", text: "最初の依頼", text_elements: [] }] },
{ type: "agentMessage", id: "draft", text: "途中経過", phase: null, memoryCitation: null },
{ type: "agentMessage", id: "a1", text: "最終回答", phase: "final_answer", memoryCitation: null },
]),
]);
expect(turns).toEqual([
{ userText: "最初の依頼", assistantText: "最終回答" },
{ userText: "次の依頼", assistantText: "次の回答" },
]);
});
it("builds an untruncated reference prompt with the 20 turn limit noted", () => {
const longText = "x".repeat(5000);
const prompt = referencedThreadPrompt(thread(), [{ userText: longText, assistantText: "回答" }], "この続きです");
expect(prompt).toContain("Included turns: 1/20");
expect(prompt).toContain(longText);
expect(prompt).toContain("Current user request:\nこの続きです");
});
it("extracts display text and metadata from a reference prompt", () => {
const prompt = referencedThreadPrompt(thread(), [{ userText: "元の依頼", assistantText: "回答" }], "この続きです");
expect(referencedThreadDisplayFromPrompt(prompt)).toEqual({
text: "この続きです",
reference: {
threadId: "019abcde-0000-7000-8000-000000000001",
title: "参照元",
includedTurns: 1,
turnLimit: 20,
},
});
});
});

View file

@ -534,6 +534,43 @@ describe("message stream block identity and message actions", () => {
});
});
it("renders referenced thread metadata without exposing hidden context", () => {
const blocks = messageRenderBlocks({
activeThreadId: "thread",
activeTurnId: null,
historyCursor: null,
loadingHistory: false,
busy: false,
displayItems: [
{
id: "u1",
kind: "message",
role: "user",
text: "この続きです",
copyText: "この続きです",
markdown: true,
referencedThread: {
threadId: "thread-reference",
title: "参照元",
includedTurns: 2,
turnLimit: 20,
},
},
],
openDetails: new Set(),
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }),
});
const user = blocks.find((block) => block.key === "item:u1")?.render();
expect(user?.querySelector(".codex-panel__message-content")?.textContent).toBe("この続きです");
expect(user?.querySelector(".codex-panel__referenced-thread")?.textContent).toContain("Referenced 参照元");
expect(user?.querySelector(".codex-panel__referenced-thread")?.textContent).toContain("2/20 turns");
expect(user?.querySelector<HTMLElement>(".codex-panel__referenced-thread")?.title).toBe("thread-reference");
});
it("does not render the open diff action without aggregated turn diff", () => {
const blocks = messageRenderBlocks({
activeThreadId: "thread",