Skip to content

Commit bf20ec7

Browse files
authored
Merge pull request #162 from RadonX/fix/resume-session-issues
fix: restore toolUseResult and user messages on session resume
2 parents 7b3ace7 + b8e4f25 commit bf20ec7

7 files changed

Lines changed: 283 additions & 11 deletions

File tree

src/app/query.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,21 @@ export async function* query(
489489
toolUseContext.options?.persistSession !== false &&
490490
process.env.NODE_ENV !== 'test'
491491

492+
// Persist the last user message that triggered this query (if it's a text message, not a tool result)
493+
// This ensures user prompts are saved to the session file for resume/undo functionality
494+
if (shouldPersistSession && messages.length > 0) {
495+
const lastMessage = messages[messages.length - 1]
496+
if (
497+
lastMessage?.type === 'user' &&
498+
(typeof lastMessage.message.content === 'string' ||
499+
(Array.isArray(lastMessage.message.content) &&
500+
lastMessage.message.content.length > 0 &&
501+
lastMessage.message.content[0]?.type !== 'tool_result'))
502+
) {
503+
appendSessionJsonlFromMessage({ message: lastMessage, toolUseContext })
504+
}
505+
}
506+
492507
for await (const message of queryCore(
493508
messages,
494509
systemPrompt,

src/ui/components/MessageSelector.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { randomUUID } from 'crypto'
88
import { type Tool } from '@tool'
99
import {
1010
createUserMessage,
11+
filterUserTextMessagesForUndo,
1112
isEmptyMessageText,
1213
isNotEmptyMessage,
1314
normalizeMessages,
@@ -49,16 +50,7 @@ export function MessageSelector({
4950

5051
const allItems = useMemo(
5152
() => [
52-
...messages
53-
.filter(
54-
_ =>
55-
!(
56-
_.type === 'user' &&
57-
Array.isArray(_.message.content) &&
58-
_.message.content[0]?.type === 'tool_result'
59-
),
60-
)
61-
.filter(_ => _.type !== 'assistant'),
53+
...filterUserTextMessagesForUndo(messages),
6254
{ ...createUserMessage(''), uuid: currentUUID } as UserMessage,
6355
],
6456
[messages, currentUUID],

src/ui/screens/REPL.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
type BinaryFeedbackResult,
3939
type Message as MessageType,
4040
type ProgressMessage,
41+
type UserMessage,
4142
query,
4243
} from '@query'
4344
import type { WrappedClient } from '@services/mcpClient'
@@ -741,7 +742,10 @@ export function REPL({
741742
<MessageSelector
742743
erroredToolUseIDs={erroredToolUseIDs}
743744
unresolvedToolUseIDs={unresolvedToolUseIDs}
744-
messages={normalizeMessagesForAPI(messages)}
745+
messages={messages.filter(
746+
(m): m is UserMessage | AssistantMessage =>
747+
m.type === 'user' || m.type === 'assistant',
748+
)}
745749
onSelect={async message => {
746750
setIsMessageSelectorVisible(false)
747751

src/utils/messages/core.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,22 @@ export function isEmptyMessageText(text: string): boolean {
608608
text.trim() === NO_CONTENT_MESSAGE
609609
)
610610
}
611+
612+
/**
613+
* Filter messages to get user text messages for the undo menu (2xESC).
614+
* Excludes:
615+
* - Assistant messages
616+
* - User messages that only contain tool_result blocks
617+
*/
618+
export function filterUserTextMessagesForUndo(
619+
messages: (UserMessage | AssistantMessage)[],
620+
): UserMessage[] {
621+
return messages.filter((msg): msg is UserMessage => {
622+
if (msg.type !== 'user') return false
623+
if (!Array.isArray(msg.message.content)) return true
624+
return !msg.message.content.every(block => block.type === 'tool_result')
625+
})
626+
}
611627
const STRIPPED_TAGS = [
612628
'commit_analysis',
613629
'context',

src/utils/protocol/kodeAgentSessionLoad.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ function normalizeLoadedUser(entry: JsonlUserEntry): Message | null {
116116
type: 'user',
117117
uuid: entry.uuid as any,
118118
message: entry.message as any,
119+
...(entry.toolUseResult !== undefined
120+
? { toolUseResult: { data: entry.toolUseResult, resultForAssistant: '' } }
121+
: {}),
119122
}
120123
}
121124

tests/unit/messages-normalization-reorder.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createAssistantMessage,
55
createProgressMessage,
66
createUserMessage,
7+
filterUserTextMessagesForUndo,
78
getInProgressToolUseIDs,
89
getUnresolvedToolUseIDs,
910
normalizeMessages,
@@ -114,4 +115,29 @@ describe('messages normalization + reordering parity', () => {
114115
expect(getUnresolvedToolUseIDs(normalized)).toEqual(new Set(['t1', 't2']))
115116
expect(getInProgressToolUseIDs(normalized)).toEqual(new Set(['t1', 't2']))
116117
})
118+
119+
test('filterUserTextMessagesForUndo excludes tool_result-only messages', () => {
120+
const messages = [
121+
createUserMessage('hello'),
122+
makeToolResult('t1'),
123+
createAssistantMessage('response'),
124+
]
125+
const result = filterUserTextMessagesForUndo(messages as any)
126+
expect(result).toHaveLength(1)
127+
expect(result[0]!.message.content).toBe('hello')
128+
})
129+
130+
test('filterUserTextMessagesForUndo keeps user text after tool_results', () => {
131+
const messages = [
132+
createUserMessage('first'),
133+
createAssistantMessage('response'),
134+
makeToolResult('t1'),
135+
makeToolResult('t2'),
136+
createUserMessage('second'),
137+
]
138+
const result = filterUserTextMessagesForUndo(messages as any)
139+
expect(result).toHaveLength(2)
140+
expect(result[0]!.message.content).toBe('first')
141+
expect(result[1]!.message.content).toBe('second')
142+
})
117143
})

tests/unit/session-load.test.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,222 @@ describe('session loader (projects/*.jsonl)', () => {
163163
)
164164
})
165165

166+
test('loads toolUseResult data from user messages with tool results', () => {
167+
const sessionId = '66666666-6666-6666-6666-666666666666'
168+
const path = getSessionLogFilePath({ cwd: projectDir, sessionId })
169+
mkdirSync(
170+
join(
171+
configDir,
172+
'projects',
173+
sanitizeProjectNameForSessionStore(projectDir),
174+
),
175+
{
176+
recursive: true,
177+
},
178+
)
179+
180+
// Simulate a session with a Bash tool result that has toolUseResult data
181+
const lines =
182+
[
183+
JSON.stringify({
184+
type: 'file-history-snapshot',
185+
messageId: 'm1',
186+
snapshot: {
187+
messageId: 'm1',
188+
trackedFileBackups: {},
189+
timestamp: new Date().toISOString(),
190+
},
191+
isSnapshotUpdate: false,
192+
}),
193+
JSON.stringify({
194+
type: 'user',
195+
sessionId,
196+
uuid: 'u1',
197+
message: { role: 'user', content: 'run ls command' },
198+
}),
199+
JSON.stringify({
200+
type: 'assistant',
201+
sessionId,
202+
uuid: 'a1',
203+
message: {
204+
id: 'msg1',
205+
model: 'x',
206+
type: 'message',
207+
role: 'assistant',
208+
content: [
209+
{ type: 'text', text: 'Running ls...' },
210+
{ type: 'tool_use', id: 'toolu_bash1', name: 'Bash', input: { command: 'ls' } },
211+
],
212+
stop_reason: 'tool_use',
213+
stop_sequence: null,
214+
usage: { input_tokens: 0, output_tokens: 0 },
215+
},
216+
}),
217+
// User message with tool_result AND toolUseResult data (as saved by kodeAgentSessionLog)
218+
JSON.stringify({
219+
type: 'user',
220+
sessionId,
221+
uuid: 'u2',
222+
message: {
223+
role: 'user',
224+
content: [
225+
{
226+
type: 'tool_result',
227+
tool_use_id: 'toolu_bash1',
228+
is_error: false,
229+
content: 'file1.ts\nfile2.ts',
230+
},
231+
],
232+
},
233+
toolUseResult: {
234+
stdout: 'file1.ts\nfile2.ts',
235+
stderr: '',
236+
exitCode: 0,
237+
interrupted: false,
238+
},
239+
}),
240+
].join('\n') + '\n'
241+
writeFileSync(path, lines, 'utf8')
242+
243+
const messages = loadKodeAgentSessionMessages({
244+
cwd: projectDir,
245+
sessionId,
246+
})
247+
248+
expect(messages.length).toBe(3)
249+
250+
// Verify the tool result message has toolUseResult restored
251+
const toolResultMsg = messages[2] as any
252+
expect(toolResultMsg.type).toBe('user')
253+
expect(toolResultMsg.toolUseResult).toBeDefined()
254+
expect(toolResultMsg.toolUseResult.data).toEqual({
255+
stdout: 'file1.ts\nfile2.ts',
256+
stderr: '',
257+
exitCode: 0,
258+
interrupted: false,
259+
})
260+
})
261+
262+
test('loads FileEdit toolUseResult with filePath for UI rendering', () => {
263+
const sessionId = '77777777-7777-7777-7777-777777777777'
264+
const path = getSessionLogFilePath({ cwd: projectDir, sessionId })
265+
mkdirSync(
266+
join(
267+
configDir,
268+
'projects',
269+
sanitizeProjectNameForSessionStore(projectDir),
270+
),
271+
{
272+
recursive: true,
273+
},
274+
)
275+
276+
// Simulate a session with a FileEdit tool result
277+
const lines =
278+
[
279+
JSON.stringify({
280+
type: 'file-history-snapshot',
281+
messageId: 'm1',
282+
snapshot: {
283+
messageId: 'm1',
284+
trackedFileBackups: {},
285+
timestamp: new Date().toISOString(),
286+
},
287+
isSnapshotUpdate: false,
288+
}),
289+
JSON.stringify({
290+
type: 'user',
291+
sessionId,
292+
uuid: 'u1',
293+
message: {
294+
role: 'user',
295+
content: [
296+
{
297+
type: 'tool_result',
298+
tool_use_id: 'toolu_edit1',
299+
is_error: false,
300+
content: 'File edited successfully',
301+
},
302+
],
303+
},
304+
// This is the data shape that FileEditToolUpdatedMessage expects
305+
toolUseResult: {
306+
filePath: '/path/to/file.ts',
307+
structuredPatch: [
308+
{
309+
oldStart: 1,
310+
oldLines: 1,
311+
newStart: 1,
312+
newLines: 2,
313+
lines: ['-old line', '+new line', '+another line'],
314+
},
315+
],
316+
},
317+
}),
318+
].join('\n') + '\n'
319+
writeFileSync(path, lines, 'utf8')
320+
321+
const messages = loadKodeAgentSessionMessages({
322+
cwd: projectDir,
323+
sessionId,
324+
})
325+
326+
expect(messages.length).toBe(1)
327+
328+
const toolResultMsg = messages[0] as any
329+
expect(toolResultMsg.toolUseResult).toBeDefined()
330+
expect(toolResultMsg.toolUseResult.data.filePath).toBe('/path/to/file.ts')
331+
expect(toolResultMsg.toolUseResult.data.structuredPatch).toHaveLength(1)
332+
})
333+
334+
test('handles user messages without toolUseResult gracefully', () => {
335+
const sessionId = '88888888-8888-8888-8888-888888888888'
336+
const path = getSessionLogFilePath({ cwd: projectDir, sessionId })
337+
mkdirSync(
338+
join(
339+
configDir,
340+
'projects',
341+
sanitizeProjectNameForSessionStore(projectDir),
342+
),
343+
{
344+
recursive: true,
345+
},
346+
)
347+
348+
// User message without toolUseResult (plain text message)
349+
const lines =
350+
[
351+
JSON.stringify({
352+
type: 'file-history-snapshot',
353+
messageId: 'm1',
354+
snapshot: {
355+
messageId: 'm1',
356+
trackedFileBackups: {},
357+
timestamp: new Date().toISOString(),
358+
},
359+
isSnapshotUpdate: false,
360+
}),
361+
JSON.stringify({
362+
type: 'user',
363+
sessionId,
364+
uuid: 'u1',
365+
message: { role: 'user', content: 'hello' },
366+
// No toolUseResult field
367+
}),
368+
].join('\n') + '\n'
369+
writeFileSync(path, lines, 'utf8')
370+
371+
const messages = loadKodeAgentSessionMessages({
372+
cwd: projectDir,
373+
sessionId,
374+
})
375+
376+
expect(messages.length).toBe(1)
377+
const msg = messages[0] as any
378+
expect(msg.type).toBe('user')
379+
expect(msg.toolUseResult).toBeUndefined()
380+
})
381+
166382
test('findMostRecentKodeAgentSessionId picks newest jsonl by mtime', () => {
167383
const projectRoot = join(
168384
configDir,

0 commit comments

Comments
 (0)