Skip to content

Commit 38fc201

Browse files
committed
feat: update Edit tool with broader backslash matching; add support for stripping accidental read-result tabs in Edit tool; add /continue command to resume conversations and update related functionality
1 parent 1878c15 commit 38fc201

9 files changed

Lines changed: 271 additions & 10 deletions

File tree

src/cli.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ if (args.includes("--help") || args.includes("-h")) {
4242
" /new Start a fresh conversation",
4343
" /init Initialize an AGENTS.md file with instructions for LLM",
4444
" /resume Pick a previous conversation to continue",
45+
" /continue Continue the active conversation, or resume one if empty",
4546
" /exit Quit",
4647
" ctrl+d twice Quit",
4748
].join("\n") + "\n"

src/session.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,12 @@ ${skillMd}
957957
return;
958958
}
959959

960+
if (this.isContinuePrompt(userPrompt)) {
961+
this.activeSessionId = sessionId;
962+
await this.activateSession(sessionId, controller);
963+
return;
964+
}
965+
960966
this.reportNewPrompt();
961967

962968
if (userPrompt.text) {
@@ -996,6 +1002,15 @@ ${skillMd}
9961002
await this.activateSession(sessionId, controller);
9971003
}
9981004

1005+
private isContinuePrompt(userPrompt: UserPromptContent): boolean {
1006+
return (
1007+
typeof userPrompt.text === "string" &&
1008+
userPrompt.text.trim() === "/continue" &&
1009+
(!userPrompt.imageUrls || userPrompt.imageUrls.length === 0) &&
1010+
(!userPrompt.skills || userPrompt.skills.length === 0)
1011+
);
1012+
}
1013+
9991014
async activateSession(sessionId: string, controller?: AbortController): Promise<void> {
10001015
const startedAt = Date.now();
10011016
const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } =
@@ -1055,6 +1070,23 @@ ${skillMd}
10551070
return;
10561071
}
10571072

1073+
const pendingToolCalls = this.getTrailingPendingToolCalls(this.listSessionMessages(sessionId));
1074+
if (pendingToolCalls.length > 0) {
1075+
const toolAppendResult = await this.appendToolMessages(sessionId, pendingToolCalls);
1076+
if (this.isInterrupted(sessionId)) {
1077+
return;
1078+
}
1079+
if (toolAppendResult.waitingForUser) {
1080+
this.updateSessionEntry(sessionId, (entry) => ({
1081+
...entry,
1082+
toolCalls: pendingToolCalls,
1083+
status: "waiting_for_user",
1084+
updateTime: new Date().toISOString(),
1085+
}));
1086+
return;
1087+
}
1088+
}
1089+
10581090
const compactPromptTokenThreshold = getCompactPromptTokenThreshold(model);
10591091
if (session.activeTokens > compactPromptTokenThreshold) {
10601092
const message = this.buildAssistantMessage(
@@ -1859,6 +1891,20 @@ ${skillMd}
18591891
return pairings;
18601892
}
18611893

1894+
private getTrailingPendingToolCalls(messages: SessionMessage[]): unknown[] {
1895+
const activeMessages = messages.filter((message) => !message.compacted);
1896+
const latestMessage = activeMessages[activeMessages.length - 1];
1897+
if (!latestMessage || latestMessage.role !== "assistant") {
1898+
return [];
1899+
}
1900+
1901+
const toolCalls = this.getAssistantToolCalls(latestMessage);
1902+
if (toolCalls.length === 0) {
1903+
return [];
1904+
}
1905+
return toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall)));
1906+
}
1907+
18621908
private findPairableToolMessageIndex(
18631909
messages: SessionMessage[],
18641910
assistantIndex: number,

src/tests/session.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,100 @@ test("replySession reports a new prompt with the machineId token", async () => {
723723
assert.equal((fetchCalls[0].init?.headers as Record<string, string>).Token, "machine-id-456");
724724
});
725725

726+
test("replySession continues without appending /continue as a user message", async () => {
727+
const workspace = createTempDir("deepcode-continue-workspace-");
728+
const home = createTempDir("deepcode-continue-home-");
729+
setHomeDir(home);
730+
731+
const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = [];
732+
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
733+
fetchCalls.push({ input, init });
734+
return {
735+
ok: true,
736+
text: async () => "",
737+
} as Response;
738+
}) as typeof fetch;
739+
740+
const manager = createSessionManager(workspace, "machine-id-continue");
741+
const activatedSessionIds: string[] = [];
742+
(manager as any).activateSession = async (sessionId: string) => {
743+
activatedSessionIds.push(sessionId);
744+
};
745+
746+
const sessionId = await manager.createSession({ text: "first prompt" });
747+
await flushPromises();
748+
const messagesBefore = manager.listSessionMessages(sessionId);
749+
fetchCalls.length = 0;
750+
activatedSessionIds.length = 0;
751+
752+
await manager.replySession(sessionId, { text: "/continue" });
753+
await flushPromises();
754+
755+
const messagesAfter = manager.listSessionMessages(sessionId);
756+
const userMessages = messagesAfter.filter((message) => message.role === "user");
757+
758+
assert.equal(activatedSessionIds.length, 1);
759+
assert.equal(activatedSessionIds[0], sessionId);
760+
assert.equal(messagesAfter.length, messagesBefore.length);
761+
assert.equal(
762+
userMessages.some((message) => message.content === "/continue"),
763+
false
764+
);
765+
assert.equal(fetchCalls.length, 0);
766+
});
767+
768+
test("replySession /continue runs trailing pending tool calls before requesting another response", async () => {
769+
const workspace = createTempDir("deepcode-continue-tool-workspace-");
770+
const home = createTempDir("deepcode-continue-tool-home-");
771+
setHomeDir(home);
772+
773+
const responses = [
774+
createChatResponse("continued after tool", {
775+
prompt_tokens: 9,
776+
completion_tokens: 2,
777+
total_tokens: 11,
778+
}),
779+
];
780+
const manager = createMockedClientSessionManager(workspace, responses);
781+
const originalActivateSession = manager.activateSession.bind(manager);
782+
(manager as any).activateSession = async () => {};
783+
784+
const sessionId = await manager.createSession({ text: "first prompt" });
785+
const pendingAssistant = (manager as any).buildAssistantMessage(
786+
sessionId,
787+
"Need to read a file",
788+
[
789+
{
790+
id: "call-pending-read",
791+
type: "function",
792+
function: { name: "read", arguments: JSON.stringify({ file_path: path.join(workspace, "note.txt") }) },
793+
},
794+
],
795+
null
796+
) as SessionMessage;
797+
fs.writeFileSync(path.join(workspace, "note.txt"), "hello from pending tool\n", "utf8");
798+
(manager as any).appendSessionMessage(sessionId, pendingAssistant);
799+
(manager as any).activateSession = originalActivateSession;
800+
801+
await manager.replySession(sessionId, { text: "/continue" });
802+
803+
const messages = manager.listSessionMessages(sessionId);
804+
const toolMessage = messages.find((message) => {
805+
const params = message.messageParams as { tool_call_id?: string } | null;
806+
return message.role === "tool" && params?.tool_call_id === "call-pending-read";
807+
});
808+
const assistantMessages = messages.filter((message) => message.role === "assistant");
809+
const userMessages = messages.filter((message) => message.role === "user");
810+
811+
assert.ok(toolMessage);
812+
assert.match(toolMessage.content ?? "", /hello from pending tool/);
813+
assert.equal(assistantMessages[assistantMessages.length - 1]?.content, "continued after tool");
814+
assert.equal(
815+
userMessages.some((message) => message.content === "/continue"),
816+
false
817+
);
818+
});
819+
726820
test("replySession preserves raw session messages when a previous tool call is pending", async () => {
727821
const workspace = createTempDir("deepcode-pending-tool-workspace-");
728822
const home = createTempDir("deepcode-pending-tool-home-");

src/tests/slashCommands.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
1919
assert.equal(items[0].kind, "skill");
2020
assert.equal(items[0].name, "skill-writer");
2121
const builtinNames = items.filter((i) => i.kind !== "skill").map((i) => i.name);
22-
assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "mcp", "exit"]);
22+
assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "continue", "mcp", "exit"]);
2323
});
2424

2525
test("filterSlashCommands matches partial prefixes", () => {
@@ -59,6 +59,13 @@ test("findExactSlashCommand returns built-in /init", () => {
5959
assert.equal(item?.description, "Initialize an AGENTS.md file with instructions for LLM");
6060
});
6161

62+
test("findExactSlashCommand returns built-in /continue", () => {
63+
const items = buildSlashCommands(skills);
64+
const item = findExactSlashCommand(items, "/continue");
65+
assert.ok(item);
66+
assert.equal(item?.kind, "continue");
67+
});
68+
6269
test("findExactSlashCommand returns built-in /skills", () => {
6370
const items = buildSlashCommands(skills);
6471
const item = findExactSlashCommand(items, "/skills");

src/tests/tool-handlers.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,80 @@ test("Edit accepts a unique loose-escape match when only escaping differs", asyn
356356
assert.equal(fs.readFileSync(filePath, "utf8"), "params['city_json'] = city\n");
357357
});
358358

359+
test("Edit accepts a unique loose-escape match for over-escaped unicode sequences", async () => {
360+
const workspace = createTempWorkspace();
361+
const filePath = path.join(workspace, "keys.ts");
362+
fs.writeFileSync(filePath, 'const sequence = "\\u001B[13;2~";\n', "utf8");
363+
364+
const sessionId = "unicode-loose-escape";
365+
await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace));
366+
367+
let llmCalls = 0;
368+
const editResult = await handleEditTool(
369+
{
370+
file_path: filePath,
371+
old_string: 'const sequence = "\\\\u001B[13;2~";',
372+
new_string: 'const sequence = "\\\\u001B[13;130u";',
373+
},
374+
createContext(sessionId, workspace, {
375+
createOpenAIClient: () => ({
376+
client: {
377+
chat: {
378+
completions: {
379+
create: async (request: { messages?: Array<{ content?: string }> }) => {
380+
llmCalls += 1;
381+
assert.match(String(request.messages?.[1]?.content ?? ""), /<matched_text><!\[CDATA\[/);
382+
return {
383+
choices: [
384+
{
385+
message: {
386+
content:
387+
"<response>" +
388+
'<corrected_old_string><![CDATA[const sequence = "\\u001B[13;2~";]]></corrected_old_string>' +
389+
'<corrected_new_string><![CDATA[const sequence = "\\u001B[13;130u";]]></corrected_new_string>' +
390+
"</response>",
391+
},
392+
},
393+
],
394+
};
395+
},
396+
},
397+
},
398+
} as any,
399+
model: "test-model",
400+
thinkingEnabled: false,
401+
}),
402+
})
403+
);
404+
405+
assert.equal(editResult.ok, true);
406+
assert.equal(llmCalls, 1);
407+
assert.equal(editResult.metadata?.matched_via, "llm_escape_correction");
408+
assert.equal(fs.readFileSync(filePath, "utf8"), 'const sequence = "\\u001B[13;130u";\n');
409+
});
410+
411+
test("Edit strips accidental read-result tabs after newlines when that creates a unique match", async () => {
412+
const workspace = createTempWorkspace();
413+
const filePath = path.join(workspace, "tabs.ts");
414+
fs.writeFileSync(filePath, ["function demo() {", " return 1;", "}"].join("\n") + "\n", "utf8");
415+
416+
const sessionId = "line-leading-tab-correction";
417+
await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace));
418+
419+
const editResult = await handleEditTool(
420+
{
421+
file_path: filePath,
422+
old_string: "function demo() {\n\t return 1;\n\t}",
423+
new_string: "function demo() {\n\t return 2;\n\t}",
424+
},
425+
createContext(sessionId, workspace)
426+
);
427+
428+
assert.equal(editResult.ok, true);
429+
assert.equal(editResult.metadata?.matched_via, "line_leading_tab_correction");
430+
assert.equal(fs.readFileSync(filePath, "utf8"), ["function demo() {", " return 2;", "}"].join("\n") + "\n");
431+
});
432+
359433
test("Write repairs JSON object content for .json files", async () => {
360434
const workspace = createTempWorkspace();
361435
const filePath = path.join(workspace, "package.json");

src/tools/edit-handler.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -213,10 +213,23 @@ export async function handleEditTool(
213213
const lineIndex = buildLineIndex(raw);
214214
const scope = buildSearchScope(filePath, raw, lineIndex, snippet ?? null);
215215
let matches = findOccurrences(raw, oldString, scope);
216-
let matchedVia: "exact" | "loose_escape" | "llm_escape_correction" = "exact";
216+
let matchedVia: "exact" | "line_leading_tab_correction" | "loose_escape" | "llm_escape_correction" = "exact";
217217
let replacementOldString = oldString;
218218
let replacementNewString = newString;
219219

220+
if (matches.length === 0) {
221+
const tabStrippedOldString = stripReadResultLineTabs(oldString);
222+
if (tabStrippedOldString !== oldString) {
223+
const tabStrippedMatches = findOccurrences(raw, tabStrippedOldString, scope);
224+
if (tabStrippedMatches.length === 1) {
225+
matches = tabStrippedMatches;
226+
matchedVia = "line_leading_tab_correction";
227+
replacementOldString = tabStrippedOldString;
228+
replacementNewString = stripReadResultLineTabs(newString);
229+
}
230+
}
231+
}
232+
220233
if (matches.length === 0) {
221234
const looseEscapeMatches = findLooseEscapeMatches(raw, oldString, scope);
222235
if (looseEscapeMatches.length === 1 && looseEscapeMatches[0]?.score === 1) {
@@ -545,6 +558,10 @@ function applyReplacement(
545558
return result;
546559
}
547560

561+
function stripReadResultLineTabs(value: string): string {
562+
return value.replaceAll("\n\t", "\n");
563+
}
564+
548565
function buildCandidateMetadata(
549566
sessionId: string,
550567
filePath: string,
@@ -691,7 +708,7 @@ function buildLooseEscapeRegex(source: string): RegExp | null {
691708
slashEnd += 1;
692709
}
693710

694-
if (slashEnd < source.length && isEscapeSensitiveChar(source[slashEnd])) {
711+
if (slashEnd < source.length) {
695712
pattern += "\\\\*";
696713
pattern += escapeRegExp(source[slashEnd]);
697714
index = slashEnd;
@@ -807,10 +824,6 @@ function parseCorrectedEditStrings(content: string): CorrectedEditStrings | null
807824
return null;
808825
}
809826

810-
function isEscapeSensitiveChar(value: string): boolean {
811-
return value === '"' || value === "'" || value === "`" || value === "\\";
812-
}
813-
814827
function escapeRegExp(value: string): string {
815828
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
816829
}

src/ui/App.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,12 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
190190
setView("session-list");
191191
return;
192192
}
193+
if (submission.command === "continue" && isCurrentSessionEmpty(sessionManager)) {
194+
setShowWelcome(false);
195+
refreshSessionsList();
196+
setView("session-list");
197+
return;
198+
}
193199
if (submission.command === "mcp") {
194200
setShowWelcome(false);
195201
setMcpStatuses(sessionManager.getMcpStatus());
@@ -211,7 +217,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
211217
(selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") ||
212218
(submission.imageUrls.length > 0 ? "[Image]" : "");
213219

214-
if (userDisplayContent) {
220+
if (userDisplayContent && submission.command !== "continue") {
215221
setMessages((prev) => [...prev, buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length)]);
216222
}
217223

@@ -506,6 +512,11 @@ function buildSyntheticUserMessage(content: string, imageCount: number): Session
506512
};
507513
}
508514

515+
function isCurrentSessionEmpty(sessionManager: SessionManager): boolean {
516+
const activeSessionId = sessionManager.getActiveSessionId();
517+
return !activeSessionId || !sessionManager.getSession(activeSessionId);
518+
}
519+
509520
function buildStatusLine(entry: SessionEntry): string {
510521
const parts: string[] = [];
511522
parts.push(`status: ${entry.status}`);

0 commit comments

Comments
 (0)