Skip to content

Commit 13bca31

Browse files
committed
feat: implement buildPromptHistory function and refactor related components to enhance message handling
1 parent 6247631 commit 13bca31

9 files changed

Lines changed: 96 additions & 23 deletions

File tree

packages/cli/src/tests/ui-utils.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
3-
import type { SessionEntry } from "@vegamo/deepcode-core";
4-
import { buildStatusLine, formatContextUsage, formatTokenCount } from "../ui/utils/index";
3+
import type { SessionEntry, SessionMessage } from "@vegamo/deepcode-core";
4+
import { buildPromptHistory, buildStatusLine, formatContextUsage, formatTokenCount } from "../ui/utils/index";
5+
6+
test("buildPromptHistory includes only visible non-empty user messages", () => {
7+
const messages = [
8+
buildMessage("user", " first prompt ", true),
9+
buildMessage("user", "internal image follow-up", false),
10+
buildMessage("system", "system prompt", false),
11+
buildMessage("assistant", "assistant reply", true),
12+
buildMessage("user", " ", true),
13+
buildMessage("user", "second prompt", true),
14+
];
15+
16+
assert.deepEqual(buildPromptHistory(messages), ["first prompt", "second prompt"]);
17+
});
518

619
test("formatTokenCount uses binary K/M units with at most one decimal", () => {
720
assert.equal(formatTokenCount(0), "0");
@@ -86,3 +99,18 @@ test("buildStatusLine omits an empty model", () => {
8699
"status: pending"
87100
);
88101
});
102+
103+
function buildMessage(role: SessionMessage["role"], content: string, visible: boolean): SessionMessage {
104+
return {
105+
id: `${role}-${content}`,
106+
sessionId: "session-1",
107+
role,
108+
content,
109+
contentParams: null,
110+
messageParams: null,
111+
compacted: false,
112+
visible,
113+
createTime: "2026-01-01T00:00:00.000Z",
114+
updateTime: "2026-01-01T00:00:00.000Z",
115+
};
116+
}

packages/cli/src/ui/utils/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export function buildSyntheticUserMessage(content: string, imageCount: number):
4848
};
4949
}
5050

51+
export function buildPromptHistory(messages: SessionMessage[]): string[] {
52+
return messages
53+
.filter((message) => message.role === "user" && message.visible && typeof message.content === "string")
54+
.map((message) => (message.content ?? "").trim())
55+
.filter((content) => content.length > 0);
56+
}
57+
5158
export function buildPromptDraftFromSessionMessage(message: SessionMessage, nonce: number): PromptDraft {
5259
return {
5360
nonce,

packages/cli/src/ui/views/App.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { RawMode, useRawModeContext } from "../contexts";
2626
import { renderMessageToStdout } from "../components/MessageView/utils";
2727
import {
2828
buildPromptDraftFromSessionMessage,
29+
buildPromptHistory,
2930
buildStatusLine,
3031
buildSyntheticUserMessage,
3132
formatModelConfig,
@@ -832,12 +833,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
832833
};
833834
}, [sessionManager, projectRoot]);
834835
const statusLineSegments = useStatusLine(resolvedSettings.statusline, projectRoot, getSessionInfo);
835-
const promptHistory = useMemo(() => {
836-
return messages
837-
.filter((message) => message.role === "user" && typeof message.content === "string")
838-
.map((message) => (message.content ?? "").trim())
839-
.filter((content) => content.length > 0);
840-
}, [messages]);
836+
const promptHistory = useMemo(() => buildPromptHistory(messages), [messages]);
841837
const expandedThinkingId = findExpandedThinkingId(messages);
842838
const pendingQuestion = useMemo(() => findPendingAskUserQuestion(messages, activeStatus), [activeStatus, messages]);
843839
const shouldShowQuestionPrompt = Boolean(pendingQuestion && !dismissedQuestionIds.has(pendingQuestion.messageId));

packages/core/src/common/permissions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as fs from "fs";
22
import * as path from "path";
33
import type { DeepcodingSettings, PermissionScope, PermissionSettings } from "../settings";
4+
import type { ToolExecutionFollowUpMessage } from "./tool-types";
45
import { isAbsoluteFilePath, normalizeFilePath } from "./state";
56

67
export type BashPermissionScope = Exclude<PermissionScope, "mcp"> | "unknown";
@@ -46,7 +47,7 @@ export type PermissionToolExecution = {
4647
error?: string;
4748
metadata?: Record<string, unknown>;
4849
awaitUserResponse?: boolean;
49-
followUpMessages?: Array<{ role: "system"; content: string; contentParams?: unknown | null }>;
50+
followUpMessages?: ToolExecutionFollowUpMessage[];
5051
};
5152
};
5253

packages/core/src/common/tool-types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,10 @@ export type ToolExecutionResult = {
9797
};
9898

9999
export type ToolExecutionFollowUpMessage = {
100-
role: "system";
100+
role: "system" | "user";
101101
content: string;
102102
contentParams?: unknown | null;
103+
visible?: boolean;
103104
};
104105

105106
export type ToolHandler = (

packages/core/src/session.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
type PluginRateLimitedTool,
2828
type ToolCallExecution,
2929
type ToolExecutionHooks,
30+
type ToolExecutionFollowUpMessage,
3031
type ToolExecutionResult,
3132
} from "./tools/executor";
3233
import { McpManager } from "./mcp/mcp-manager";
@@ -2522,6 +2523,22 @@ ${agentInstructions}
25222523
};
25232524
}
25242525

2526+
private buildFollowUpMessage(sessionId: string, message: ToolExecutionFollowUpMessage): SessionMessage {
2527+
const now = new Date().toISOString();
2528+
return {
2529+
id: crypto.randomUUID(),
2530+
sessionId,
2531+
role: message.role,
2532+
content: message.content,
2533+
contentParams: message.contentParams ?? null,
2534+
messageParams: null,
2535+
compacted: false,
2536+
visible: message.visible ?? false,
2537+
createTime: now,
2538+
updateTime: now,
2539+
};
2540+
}
2541+
25252542
private buildSkillMessage(sessionId: string, content: string, skill: SkillInfo): SessionMessage {
25262543
const now = new Date().toISOString();
25272544
return {
@@ -2724,12 +2741,7 @@ ${agentInstructions}
27242741
this.onAssistantMessage(toolMessage, true);
27252742

27262743
for (const followUpMessage of execution.result.followUpMessages ?? []) {
2727-
if (followUpMessage.role !== "system") {
2728-
continue;
2729-
}
2730-
followUpMessages.push(
2731-
this.buildSystemMessage(sessionId, followUpMessage.content, followUpMessage.contentParams ?? null)
2732-
);
2744+
followUpMessages.push(this.buildFollowUpMessage(sessionId, followUpMessage));
27332745
}
27342746
}
27352747

packages/core/src/tests/session.test.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ test("getProjectCode shortens long project roots for Windows-compatible storage
6363
assert.notEqual(projectCode, longRoot.replace(/[\\/]/g, "-").replace(/:/g, ""));
6464
});
6565

66-
test("SessionManager preserves structured system content when building OpenAI messages", () => {
66+
test("SessionManager preserves structured user content when building OpenAI messages", () => {
6767
const manager = new SessionManager({
6868
projectRoot: process.cwd(),
6969
createOpenAIClient: () => ({
@@ -78,9 +78,9 @@ test("SessionManager preserves structured system content when building OpenAI me
7878

7979
const messages: SessionMessage[] = [
8080
{
81-
id: "system-image",
81+
id: "user-image",
8282
sessionId: "session-1",
83-
role: "system",
83+
role: "user",
8484
content: "The read tool has loaded `pixel.png`.",
8585
contentParams: [
8686
{
@@ -102,7 +102,7 @@ test("SessionManager preserves structured system content when building OpenAI me
102102
}>;
103103

104104
assert.equal(openAIMessages.length, 1);
105-
assert.equal(openAIMessages[0]?.role, "system");
105+
assert.equal(openAIMessages[0]?.role, "user");
106106
assert.deepEqual(openAIMessages[0]?.content, [
107107
{ type: "text", text: "The read tool has loaded `pixel.png`." },
108108
{
@@ -112,6 +112,32 @@ test("SessionManager preserves structured system content when building OpenAI me
112112
]);
113113
});
114114

115+
test("SessionManager builds hidden user messages for image follow-ups", () => {
116+
const manager = new SessionManager({
117+
projectRoot: process.cwd(),
118+
createOpenAIClient: () => ({
119+
client: null,
120+
model: "test-model",
121+
thinkingEnabled: false,
122+
}),
123+
getResolvedSettings: () => ({ model: "test-model" }),
124+
renderMarkdown: (text) => text,
125+
onAssistantMessage: () => {},
126+
});
127+
128+
const message = (manager as any).buildFollowUpMessage("session-1", {
129+
role: "user",
130+
content: "The read tool has loaded `pixel.png`.",
131+
contentParams: [{ type: "image_url", image_url: { url: "data:image/png;base64,abc123" } }],
132+
visible: false,
133+
});
134+
135+
assert.equal(message.role, "user");
136+
assert.equal(message.visible, false);
137+
assert.equal(message.meta, undefined);
138+
assert.equal(message.checkpointHash, undefined);
139+
});
140+
115141
test("non-interactive SessionManager removes AskUserQuestion docs from restored requests", () => {
116142
const manager = new SessionManager({
117143
projectRoot: process.cwd(),

packages/core/src/tests/tool-handlers.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,7 +1197,7 @@ test("Edit preserves CRLF line endings for existing files", async () => {
11971197
assert.equal(fs.readFileSync(filePath, "utf8"), "alpha\r\ngamma\r\n");
11981198
});
11991199

1200-
test("Read returns an acknowledgement for images and attaches the image as a follow-up system message", async () => {
1200+
test("Read returns an acknowledgement for images and attaches the image as a hidden user message", async () => {
12011201
const workspace = createTempWorkspace();
12021202
const filePath = path.join(workspace, "pixel.png");
12031203
fs.writeFileSync(
@@ -1217,7 +1217,8 @@ test("Read returns an acknowledgement for images and attaches the image as a fol
12171217
assert.equal(readResult.followUpMessages?.length, 1);
12181218

12191219
const followUpMessage = readResult.followUpMessages?.[0];
1220-
assert.equal(followUpMessage?.role, "system");
1220+
assert.equal(followUpMessage?.role, "user");
1221+
assert.equal(followUpMessage?.visible, false);
12211222
assert.match(followUpMessage?.content ?? "", /pixel\.png/);
12221223
const contentParams = Array.isArray(followUpMessage?.contentParams) ? followUpMessage.contentParams : [];
12231224
assert.equal(contentParams.length, 1);

packages/core/src/tools/read-handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ function getImageMimeType(ext: string): string {
459459
function buildImageFollowUpMessage(filePath: string, mime: string, buffer: Buffer): ToolExecutionFollowUpMessage {
460460
const fileName = path.basename(filePath);
461461
return {
462-
role: "system",
462+
role: "user",
463463
content:
464464
`The read tool has loaded \`${fileName}\`. ` + "Use the attached image content to answer the original request.",
465465
contentParams: [
@@ -470,6 +470,7 @@ function buildImageFollowUpMessage(filePath: string, mime: string, buffer: Buffe
470470
},
471471
},
472472
],
473+
visible: false,
473474
};
474475
}
475476

0 commit comments

Comments
 (0)