Recover first prompt for resume sessions

This commit is contained in:
weishu
2026-05-20 20:35:03 +08:00
parent 856af6d8b2
commit 5264599908
4 changed files with 103 additions and 8 deletions
+10 -1
View File
@@ -22,7 +22,16 @@ describe('isExternalUserMessage', () => {
expect(isExternalUserMessage({ ...baseUserMsg, isSidechain: true })).toBe(false)
})
it('returns false when content is an array (tool results)', () => {
it('returns true when content is an array of text blocks', () => {
expect(
isExternalUserMessage({
...baseUserMsg,
message: { role: 'user', content: [{ type: 'text', text: 'hello array' }] },
} as never)
).toBe(true)
})
it('returns false when content is a non-text array (tool results)', () => {
expect(
isExternalUserMessage({
...baseUserMsg,
+27 -4
View File
@@ -48,6 +48,28 @@ const SYSTEM_INJECTION_PREFIXES = [
'<system-reminder>',
]
function extractRawUserTextContent(content: unknown): string | null {
if (typeof content === 'string') {
return content
}
if (!Array.isArray(content)) {
return null
}
const parts = content
.map((block) => {
if (!block || typeof block !== 'object' || Array.isArray(block)) return null
const record = block as Record<string, unknown>
return record.type === 'text' && typeof record.text === 'string'
? record.text
: null
})
.filter((text): text is string => text !== null)
return parts.length > 0 ? parts.join('\n') : null
}
/**
* Returns true if a JSONL message should be classified as a user-role message
* (i.e., text typed by a real human) rather than an agent-role message.
@@ -58,13 +80,14 @@ const SYSTEM_INJECTION_PREFIXES = [
* genuine user messages, so the only reliable signal is the message content
* itself: injected messages always start with a well-known XML tag.
*/
export function isExternalUserMessage(body: RawJSONLines): body is Extract<RawJSONLines, { type: 'user' }> & { message: { content: string } } {
export function isExternalUserMessage(body: RawJSONLines): body is Extract<RawJSONLines, { type: 'user' }> {
if (body.type !== 'user') return false
if (typeof body.message.content !== 'string') return false
const text = extractRawUserTextContent(body.message.content)
if (text === null) return false
if (body.isSidechain === true) return false
if (body.isMeta === true) return false
const trimmed = body.message.content.trimStart()
const trimmed = text.trimStart()
for (const prefix of SYSTEM_INJECTION_PREFIXES) {
if (trimmed.startsWith(prefix)) return false
}
@@ -440,7 +463,7 @@ export class ApiSessionClient extends EventEmitter {
role: 'user',
content: {
type: 'text',
text: body.message.content
text: extractRawUserTextContent(body.message.content) ?? ''
},
meta: {
sentFrom: 'cli'
+48
View File
@@ -1034,6 +1034,54 @@ describe('session model', () => {
}
})
it('recovers first user message from stored Claude user output events', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-first-claude-output',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude',
claudeSessionId: '11111111-1111-4111-8111-111111111111',
name: 'Generated title'
},
null,
'default'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: 'First Claude prompt' }]
}
}
}
})
const sessions = engine.listLocalResumableSessions('default', { machineId: 'machine-1' })
expect(sessions.find((item) => item.sessionId === session.id)).toMatchObject({
name: 'Generated title',
firstUserMessage: 'First Claude prompt'
})
} finally {
engine.stop()
}
})
it('local handoff succeeds immediately for inactive sessions', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
+18 -3
View File
@@ -98,6 +98,19 @@ function extractUserMessageText(content: unknown): string | undefined {
return undefined
}
function extractClaudeUserMessageTextFromAgentOutput(content: unknown): string | undefined {
const record = asRecord(content)
if (record?.type !== 'output') return undefined
const data = asRecord(record.data)
if (data?.type !== 'user') return undefined
const message = asRecord(data.message)
if (message?.role !== 'user') return undefined
return extractUserMessageText(message.content)
}
export class SyncEngine {
private readonly eventPublisher: EventPublisher
private readonly sessionCache: SessionCache
@@ -569,9 +582,11 @@ export class SyncEngine {
private resolveFirstUserMessage(sessionId: string): string | undefined {
for (const message of this.store.messages.getFirstMessages(sessionId, 50)) {
const roleWrapped = unwrapRoleWrappedRecordEnvelope(message.content)
if (roleWrapped?.role !== 'user') continue
const text = extractUserMessageText(roleWrapped.content)
const text = roleWrapped?.role === 'user'
? extractUserMessageText(roleWrapped.content)
: roleWrapped?.role === 'agent'
? extractClaudeUserMessageTextFromAgentOutput(roleWrapped.content)
: undefined
if (text) return text
}