diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts index 6078e0ee..a44ebc86 100644 --- a/cli/src/api/apiSession.test.ts +++ b/cli/src/api/apiSession.test.ts @@ -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, diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 398f13c0..10992dc8 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -48,6 +48,28 @@ const SYSTEM_INJECTION_PREFIXES = [ '', ] +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 + 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 & { message: { content: string } } { +export function isExternalUserMessage(body: RawJSONLines): body is Extract { 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' diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 49ef20bf..6e038a1d 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -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( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index c4bff8b7..76d56e73 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -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 }