mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
Polish issue chat chain-of-thought rendering
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
2a372fbe8a
commit
627fbc80ac
7 changed files with 404 additions and 37 deletions
|
|
@ -245,7 +245,7 @@ describe("buildIssueChatMessages", () => {
|
|||
[{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text: "Streaming reply" }],
|
||||
],
|
||||
]),
|
||||
hasOutputForRun: () => true,
|
||||
hasOutputForRun: (runId) => runId === "run-live-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
agentMap,
|
||||
|
|
@ -269,4 +269,53 @@ describe("buildIssueChatMessages", () => {
|
|||
text: "Streaming reply",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps succeeded runs as assistant messages when transcript output exists", () => {
|
||||
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [
|
||||
{
|
||||
runId: "run-history-1",
|
||||
status: "succeeded",
|
||||
agentId: "agent-1",
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
startedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
finishedAt: new Date("2026-04-06T12:03:00.000Z"),
|
||||
},
|
||||
],
|
||||
liveRuns: [],
|
||||
transcriptsByRunId: new Map([
|
||||
[
|
||||
"run-history-1",
|
||||
[
|
||||
{ kind: "thinking", ts: "2026-04-06T12:01:10.000Z", text: "Checking the current issue thread." },
|
||||
{ kind: "assistant", ts: "2026-04-06T12:02:30.000Z", text: "Updated the thread renderer." },
|
||||
],
|
||||
],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-history-1",
|
||||
agentMap,
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "historical-run:run-history-1",
|
||||
role: "assistant",
|
||||
status: { type: "complete", reason: "stop" },
|
||||
metadata: {
|
||||
custom: {
|
||||
kind: "historical-run",
|
||||
runId: "run-history-1",
|
||||
chainOfThoughtLabel: "Worked for 2 minutes",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(messages[0]?.content).toMatchObject([
|
||||
{ type: "reasoning", text: "Checking the current issue thread." },
|
||||
{ type: "text", text: "Updated the thread renderer." },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -281,6 +281,53 @@ function runTimestamp(run: IssueChatLinkedRun) {
|
|||
return run.finishedAt ?? run.startedAt ?? run.createdAt;
|
||||
}
|
||||
|
||||
function formatDurationWords(ms: number | null) {
|
||||
if (ms === null || !Number.isFinite(ms) || ms <= 0) return null;
|
||||
const totalSeconds = Math.max(1, Math.round(ms / 1000));
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds} second${totalSeconds === 1 ? "" : "s"}`;
|
||||
}
|
||||
const totalMinutes = Math.round(totalSeconds / 60);
|
||||
if (totalMinutes < 60) {
|
||||
return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`;
|
||||
}
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (minutes === 0) {
|
||||
return `${hours} hour${hours === 1 ? "" : "s"}`;
|
||||
}
|
||||
return `${hours} hour${hours === 1 ? "" : "s"} ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function runDurationLabel(run: {
|
||||
status: string;
|
||||
createdAt: Date | string;
|
||||
startedAt: Date | string | null;
|
||||
finishedAt?: Date | string | null;
|
||||
}) {
|
||||
const start = run.startedAt ?? run.createdAt;
|
||||
const end = run.finishedAt ?? null;
|
||||
const durationMs = end ? Math.max(0, toTimestamp(end) - toTimestamp(start)) : null;
|
||||
const durationText = formatDurationWords(durationMs);
|
||||
switch (run.status) {
|
||||
case "succeeded":
|
||||
return durationText ? `Worked for ${durationText}` : "Finished work";
|
||||
case "failed":
|
||||
case "error":
|
||||
return durationText ? `Failed after ${durationText}` : "Run failed";
|
||||
case "timed_out":
|
||||
return durationText ? `Timed out after ${durationText}` : "Run timed out";
|
||||
case "cancelled":
|
||||
return durationText ? `Cancelled after ${durationText}` : "Run cancelled";
|
||||
case "queued":
|
||||
return "Queued";
|
||||
case "running":
|
||||
return "Working...";
|
||||
default:
|
||||
return formatStatusLabel(run.status);
|
||||
}
|
||||
}
|
||||
|
||||
function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map<string, Agent>) {
|
||||
const agentName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||
const message: ThreadSystemMessage = {
|
||||
|
|
@ -302,6 +349,43 @@ function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map<stri
|
|||
return message;
|
||||
}
|
||||
|
||||
function createHistoricalTranscriptMessage(args: {
|
||||
run: IssueChatLinkedRun;
|
||||
transcript: readonly IssueChatTranscriptEntry[];
|
||||
hasOutput: boolean;
|
||||
agentMap?: Map<string, Agent>;
|
||||
}) {
|
||||
const { run, transcript, hasOutput, agentMap } = args;
|
||||
const agentName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||
const { parts, notices } = buildAssistantPartsFromTranscript(transcript);
|
||||
const waitingText = hasOutput ? "" : "Run finished";
|
||||
const content = parts.length > 0
|
||||
? parts
|
||||
: waitingText
|
||||
? [{ type: "text", text: waitingText } satisfies TextMessagePart]
|
||||
: [];
|
||||
|
||||
const message: ThreadAssistantMessage = {
|
||||
id: `historical-run:${run.runId}`,
|
||||
role: "assistant",
|
||||
createdAt: toDate(run.startedAt ?? run.createdAt),
|
||||
content,
|
||||
status: { type: "complete", reason: "stop" },
|
||||
metadata: createAssistantMetadata({
|
||||
kind: "historical-run",
|
||||
anchorId: `run-${run.runId}`,
|
||||
runId: run.runId,
|
||||
runAgentId: run.agentId,
|
||||
runAgentName: agentName,
|
||||
runStatus: run.status,
|
||||
notices,
|
||||
waitingText,
|
||||
chainOfThoughtLabel: runDurationLabel(run),
|
||||
}),
|
||||
};
|
||||
return message;
|
||||
}
|
||||
|
||||
export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTranscriptEntry[]) {
|
||||
const orderedParts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>> = [];
|
||||
const toolParts = new Map<string, ToolCallMessagePart<JsonObject, unknown>>();
|
||||
|
|
@ -495,6 +579,7 @@ function createLiveRunMessage(args: {
|
|||
adapterType: run.adapterType,
|
||||
notices,
|
||||
waitingText,
|
||||
chainOfThoughtLabel: runDurationLabel(run),
|
||||
}),
|
||||
};
|
||||
return message;
|
||||
|
|
@ -548,6 +633,21 @@ export function buildIssueChatMessages(args: {
|
|||
}
|
||||
|
||||
for (const run of [...linkedRuns].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) {
|
||||
const transcript = transcriptsByRunId?.get(run.runId) ?? [];
|
||||
const hasRunOutput = transcript.length > 0 || (hasOutputForRun?.(run.runId) ?? false);
|
||||
if (hasRunOutput) {
|
||||
orderedMessages.push({
|
||||
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
|
||||
order: 2,
|
||||
message: createHistoricalTranscriptMessage({
|
||||
run,
|
||||
transcript,
|
||||
hasOutput: hasRunOutput,
|
||||
agentMap,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (run.status === "succeeded") continue;
|
||||
orderedMessages.push({
|
||||
createdAtMs: toTimestamp(runTimestamp(run)),
|
||||
|
|
|
|||
38
ui/src/lib/transcriptPresentation.test.ts
Normal file
38
ui/src/lib/transcriptPresentation.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { describeToolInput, summarizeToolInput } from "./transcriptPresentation";
|
||||
|
||||
describe("summarizeToolInput", () => {
|
||||
it("prefers human descriptions over raw commands when both exist", () => {
|
||||
expect(
|
||||
summarizeToolInput("command_execution", {
|
||||
description: "Inspect the issue chat thread layout classes",
|
||||
command: "zsh -lc 'sed -n \"1,220p\" ui/src/components/IssueChatThread.tsx'",
|
||||
}),
|
||||
).toBe("Inspect the issue chat thread layout classes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeToolInput", () => {
|
||||
it("keeps command tools description-first in the detail view", () => {
|
||||
expect(
|
||||
describeToolInput("command_execution", {
|
||||
description: "Inspect the issue chat thread layout classes",
|
||||
command: "zsh -lc 'sed -n \"1,220p\" ui/src/components/IssueChatThread.tsx'",
|
||||
cwd: "/workspace/paperclip",
|
||||
}),
|
||||
).toEqual([
|
||||
{ label: "Intent", value: "Inspect the issue chat thread layout classes", tone: "default" },
|
||||
{ label: "Directory", value: "/workspace/paperclip", tone: "default" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces concise structured details for file tools", () => {
|
||||
expect(
|
||||
describeToolInput("read_file", {
|
||||
path: "ui/src/lib/issue-chat-messages.ts",
|
||||
}),
|
||||
).toEqual([
|
||||
{ label: "Path", value: "ui/src/lib/issue-chat-messages.ts", tone: "default" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,12 @@ type TranscriptActivity = {
|
|||
status: "running" | "completed";
|
||||
};
|
||||
|
||||
export interface ToolInputDetail {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "default" | "code";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
|
|
@ -137,13 +143,19 @@ export function summarizeToolInput(
|
|||
: typeof record.cmd === "string"
|
||||
? record.cmd
|
||||
: null;
|
||||
const humanDescription =
|
||||
summarizeRecord(record, ["description", "summary", "reason", "goal", "intent", "action", "task"])
|
||||
?? null;
|
||||
if (humanDescription) {
|
||||
return truncate(humanDescription, compactMax);
|
||||
}
|
||||
if (command && isCommandTool(name, record)) {
|
||||
return truncate(stripWrappedShell(command), compactMax);
|
||||
}
|
||||
|
||||
const direct =
|
||||
summarizeRecord(record, ["command", "cmd", "path", "filePath", "file_path", "query", "url", "prompt", "message"])
|
||||
?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool"])
|
||||
summarizeRecord(record, ["path", "filePath", "file_path", "query", "url", "prompt", "message"])
|
||||
?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool", "command", "cmd"])
|
||||
?? null;
|
||||
if (direct) return truncate(direct, compactMax);
|
||||
|
||||
|
|
@ -160,6 +172,71 @@ export function summarizeToolInput(
|
|||
return truncate(`${keys.length} fields: ${keys.slice(0, 3).join(", ")}`, compactMax);
|
||||
}
|
||||
|
||||
function readToolDetailValue(value: unknown, max = 200): string | null {
|
||||
if (typeof value === "string") {
|
||||
const normalized = compactWhitespace(value);
|
||||
return normalized ? truncate(normalized, max) : null;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function describeToolInput(name: string, input: unknown): ToolInputDetail[] {
|
||||
if (typeof input === "string") {
|
||||
const summary = compactWhitespace(isCommandTool(name, input) ? stripWrappedShell(input) : input);
|
||||
return summary ? [{ label: isCommandTool(name, input) ? "Command" : "Input", value: truncate(summary, 200), tone: "code" }] : [];
|
||||
}
|
||||
|
||||
const record = asRecord(input);
|
||||
if (!record) return [];
|
||||
|
||||
const details: ToolInputDetail[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushDetail = (label: string, value: string | null, tone: ToolInputDetail["tone"] = "default") => {
|
||||
if (!value) return;
|
||||
const key = `${label}:${value}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
details.push({ label, value, tone });
|
||||
};
|
||||
|
||||
pushDetail(
|
||||
"Intent",
|
||||
summarizeRecord(record, ["description", "summary", "reason", "goal", "intent", "action", "task"]) ?? null,
|
||||
);
|
||||
pushDetail("Path", readToolDetailValue(record.path) ?? readToolDetailValue(record.filePath) ?? readToolDetailValue(record.file_path));
|
||||
pushDetail("Directory", readToolDetailValue(record.cwd));
|
||||
pushDetail("Query", readToolDetailValue(record.query));
|
||||
pushDetail("Target", readToolDetailValue(record.url) ?? readToolDetailValue(record.target));
|
||||
pushDetail("Prompt", readToolDetailValue(record.prompt) ?? readToolDetailValue(record.message));
|
||||
pushDetail("Pattern", readToolDetailValue(record.pattern));
|
||||
pushDetail("Name", readToolDetailValue(record.name) ?? readToolDetailValue(record.title));
|
||||
|
||||
if (Array.isArray(record.paths) && record.paths.length > 0) {
|
||||
const paths = record.paths
|
||||
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
.slice(0, 3)
|
||||
.join(", ");
|
||||
if (paths) {
|
||||
const suffix = record.paths.length > 3 ? `, +${record.paths.length - 3} more` : "";
|
||||
pushDetail("Paths", `${paths}${suffix}`);
|
||||
}
|
||||
}
|
||||
|
||||
const command = typeof record.command === "string"
|
||||
? record.command
|
||||
: typeof record.cmd === "string"
|
||||
? record.cmd
|
||||
: null;
|
||||
if (command && isCommandTool(name, record) && !details.some((detail) => detail.label === "Intent")) {
|
||||
pushDetail("Command", truncate(stripWrappedShell(command), 200), "code");
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
export function summarizeToolResult(
|
||||
result: string | undefined,
|
||||
isError: boolean | undefined,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue