fix chat session lineage visibility (#228)
This commit is contained in:
@@ -113,6 +113,149 @@ describe('Chat Store', () => {
|
||||
expect(store.messages.map(m => m.content)).toEqual(['draft'])
|
||||
})
|
||||
|
||||
it('does not let a stale server refresh erase a newer local assistant reply', async () => {
|
||||
const cachedMessages = [
|
||||
{ id: 'u1', role: 'user', content: 'expensive task', timestamp: 1 },
|
||||
{ id: 'a1', role: 'assistant', content: 'final answer that already streamed', timestamp: 2 },
|
||||
]
|
||||
|
||||
window.localStorage.setItem(ACTIVE_SESSION_KEY, 'sess-stale')
|
||||
window.localStorage.setItem(
|
||||
SESSIONS_CACHE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'sess-stale',
|
||||
title: 'Stale refresh',
|
||||
source: 'api_server',
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
]),
|
||||
)
|
||||
window.localStorage.setItem(sessionMessagesKey('sess-stale'), JSON.stringify(cachedMessages))
|
||||
|
||||
mockSessionsApi.fetchSessions.mockResolvedValue([makeSummary('sess-stale', 'Stale refresh')])
|
||||
mockSessionsApi.fetchSession.mockResolvedValue(makeDetail('sess-stale', [
|
||||
{
|
||||
id: 1,
|
||||
session_id: 'sess-stale',
|
||||
role: 'user',
|
||||
content: 'expensive task',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 1710000000,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
]))
|
||||
|
||||
const store = useChatStore()
|
||||
await store.loadSessions()
|
||||
expect(store.messages.map(m => m.content)).toEqual(['expensive task', 'final answer that already streamed'])
|
||||
|
||||
await store.refreshActiveSession()
|
||||
|
||||
expect(store.messages.map(m => m.content)).toEqual(['expensive task', 'final answer that already streamed'])
|
||||
const persistedMessages = JSON.parse(window.localStorage.getItem(sessionMessagesKey('sess-stale')) || '[]')
|
||||
expect(persistedMessages.map((m: any) => m.content)).toEqual(['expensive task', 'final answer that already streamed'])
|
||||
})
|
||||
|
||||
it('does not let stale resume polling erase a newer local assistant reply', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-22T19:00:00.000Z'))
|
||||
|
||||
const cachedMessages = [
|
||||
{ id: 'u0', role: 'user', content: 'previous task', timestamp: 1 },
|
||||
{ id: 'a0', role: 'assistant', content: 'a much longer previous assistant answer', timestamp: 2 },
|
||||
{ id: 'u1', role: 'user', content: 'long task', timestamp: 3 },
|
||||
{ id: 'a1', role: 'assistant', content: 'local final answer', timestamp: 4 },
|
||||
]
|
||||
|
||||
window.localStorage.setItem(ACTIVE_SESSION_KEY, 'sess-poll-stale')
|
||||
window.localStorage.setItem(
|
||||
SESSIONS_CACHE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'sess-poll-stale',
|
||||
title: 'Polling stale refresh',
|
||||
source: 'api_server',
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
]),
|
||||
)
|
||||
window.localStorage.setItem(sessionMessagesKey('sess-poll-stale'), JSON.stringify(cachedMessages))
|
||||
window.localStorage.setItem(inFlightKey('sess-poll-stale'), JSON.stringify({ runId: 'run-1', startedAt: Date.now() }))
|
||||
|
||||
mockSessionsApi.fetchSessions.mockResolvedValue([makeSummary('sess-poll-stale', 'Polling stale refresh')])
|
||||
mockSessionsApi.fetchSession.mockResolvedValue(makeDetail('sess-poll-stale', [
|
||||
{
|
||||
id: 1,
|
||||
session_id: 'sess-poll-stale',
|
||||
role: 'user',
|
||||
content: 'previous task',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 1710000000,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
session_id: 'sess-poll-stale',
|
||||
role: 'assistant',
|
||||
content: 'a much longer previous assistant answer',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 1710000001,
|
||||
token_count: null,
|
||||
finish_reason: 'stop',
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
session_id: 'sess-poll-stale',
|
||||
role: 'user',
|
||||
content: 'long task',
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: 1710000002,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
]))
|
||||
|
||||
const store = useChatStore()
|
||||
await store.loadSessions()
|
||||
expect(store.messages.map(m => m.content)).toEqual([
|
||||
'previous task',
|
||||
'a much longer previous assistant answer',
|
||||
'long task',
|
||||
'local final answer',
|
||||
])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9000)
|
||||
await flushPromises()
|
||||
|
||||
expect(store.messages.map(m => m.content)).toEqual([
|
||||
'previous task',
|
||||
'a much longer previous assistant answer',
|
||||
'long task',
|
||||
'local final answer',
|
||||
])
|
||||
expect(store.isRunActive).toBe(false)
|
||||
expect(window.localStorage.getItem(inFlightKey('sess-poll-stale'))).toBeNull()
|
||||
})
|
||||
|
||||
it('persists the user message immediately before any SSE delta arrives', async () => {
|
||||
const store = useChatStore()
|
||||
|
||||
|
||||
@@ -195,21 +195,29 @@ describe('conversation DB service', () => {
|
||||
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
|
||||
expect(summaries).toHaveLength(1)
|
||||
expect(summaries[0]).toEqual(expect.objectContaining({
|
||||
id: 'root',
|
||||
id: 'root-cont',
|
||||
title: 'Continuation',
|
||||
started_at: 100,
|
||||
thread_session_count: 2,
|
||||
ended_at: 111,
|
||||
cost_status: 'mixed',
|
||||
actual_cost_usd: 0.30000000000000004,
|
||||
}))
|
||||
|
||||
const detail = await mod.getConversationDetailFromDb('root', { humanOnly: true })
|
||||
expect(detail?.thread_session_count).toBe(2)
|
||||
expect(detail?.messages.map((message: any) => message.content)).toEqual([
|
||||
const detailFromTip = await mod.getConversationDetailFromDb('root-cont', { humanOnly: true })
|
||||
expect(detailFromTip?.session_id).toBe('root-cont')
|
||||
expect(detailFromTip?.thread_session_count).toBe(2)
|
||||
expect(detailFromTip?.messages.map((message: any) => message.content)).toEqual([
|
||||
'Start here',
|
||||
'Assistant reply',
|
||||
'Continue with more detail',
|
||||
'Continued answer',
|
||||
])
|
||||
|
||||
const detailFromRoot = await mod.getConversationDetailFromDb('root', { humanOnly: true })
|
||||
expect(detailFromRoot?.messages.map((message: any) => message.content)).toEqual(
|
||||
detailFromTip?.messages.map((message: any) => message.content),
|
||||
)
|
||||
})
|
||||
|
||||
it('treats branched children as their own visible conversations', async () => {
|
||||
@@ -274,6 +282,69 @@ describe('conversation DB service', () => {
|
||||
expect(detail?.messages.map((message: any) => message.content)).toEqual(['Branch prompt', 'Branch answer'])
|
||||
})
|
||||
|
||||
it('keeps non-compression child sessions visible instead of hiding them under their parent', async () => {
|
||||
ensureSqliteAvailable()
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
|
||||
createSchema(db)
|
||||
|
||||
insertSession(db, {
|
||||
id: 'parent',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Parent',
|
||||
started_at: 100,
|
||||
ended_at: 150,
|
||||
end_reason: null,
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
})
|
||||
insertSession(db, {
|
||||
id: 'review-child',
|
||||
parent_session_id: 'parent',
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Independent review',
|
||||
started_at: 300,
|
||||
ended_at: 320,
|
||||
end_reason: null,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
})
|
||||
|
||||
insertMessage(db, { id: 1, session_id: 'parent', role: 'user', content: 'Parent prompt', timestamp: 101 })
|
||||
insertMessage(db, { id: 2, session_id: 'review-child', role: 'user', content: 'Review prompt', timestamp: 301 })
|
||||
insertMessage(db, { id: 3, session_id: 'review-child', role: 'assistant', content: 'Review answer', timestamp: 302 })
|
||||
db.close()
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
|
||||
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
|
||||
expect(summaries.map((summary: any) => summary.id)).toEqual(['review-child', 'parent'])
|
||||
|
||||
const detail = await mod.getConversationDetailFromDb('review-child', { humanOnly: true })
|
||||
expect(detail?.thread_session_count).toBe(1)
|
||||
expect(detail?.messages.map((message: any) => message.content)).toEqual(['Review prompt', 'Review answer'])
|
||||
})
|
||||
|
||||
it('excludes synthetic-only roots from human-only summaries and details', async () => {
|
||||
ensureSqliteAvailable()
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
|
||||
Reference in New Issue
Block a user