mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-25 21:40:13 +00:00
[codex] fix kanban session matching (#538)
* fix kanban session matching * tighten kanban task session lookup * remove favicon svg
This commit is contained in:
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -3,7 +3,12 @@ import { readFile } from 'fs/promises'
|
||||
import { resolve, normalize } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import * as kanbanCli from '../../services/hermes/hermes-kanban'
|
||||
import { searchSessionSummariesWithProfile, getSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
|
||||
import {
|
||||
searchSessionSummariesWithProfile,
|
||||
getSessionDetailFromDbWithProfile,
|
||||
getExactSessionDetailFromDbWithProfile,
|
||||
findLatestExactSessionIdWithProfile,
|
||||
} from '../../db/hermes/sessions-db'
|
||||
|
||||
function getLatestRunProfile(detail: { runs: Array<{ profile: string | null }> }): string | null {
|
||||
return [...detail.runs].reverse().find(run => run.profile)?.profile || null
|
||||
@@ -34,13 +39,12 @@ export async function get(ctx: Context) {
|
||||
const profile = getLatestRunProfile(detail)
|
||||
if (profile) {
|
||||
try {
|
||||
const results = await searchSessionSummariesWithProfile(detail.task.id, profile, undefined, 5)
|
||||
if (results.length > 0) {
|
||||
const sessionId = results[0].id
|
||||
const sessionDetail = await getSessionDetailFromDbWithProfile(sessionId, profile)
|
||||
const exactSessionId = await findLatestExactSessionIdWithProfile(detail.task.id, profile)
|
||||
if (exactSessionId) {
|
||||
const sessionDetail = await getExactSessionDetailFromDbWithProfile(exactSessionId, profile)
|
||||
if (sessionDetail) {
|
||||
;(detail as any).session = {
|
||||
id: sessionId,
|
||||
id: exactSessionId,
|
||||
title: sessionDetail.title,
|
||||
source: sessionDetail.source,
|
||||
model: sessionDetail.model,
|
||||
@@ -49,6 +53,23 @@ export async function get(ctx: Context) {
|
||||
messages: sessionDetail.messages,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const results = await searchSessionSummariesWithProfile(detail.task.id, profile, undefined, 5)
|
||||
if (results.length > 0) {
|
||||
const sessionId = results[0].id
|
||||
const sessionDetail = await getSessionDetailFromDbWithProfile(sessionId, profile)
|
||||
if (sessionDetail) {
|
||||
;(detail as any).session = {
|
||||
id: sessionId,
|
||||
title: sessionDetail.title,
|
||||
source: sessionDetail.source,
|
||||
model: sessionDetail.model,
|
||||
started_at: sessionDetail.started_at,
|
||||
ended_at: sessionDetail.ended_at,
|
||||
messages: sessionDetail.messages,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Session lookup is best-effort, don't fail the whole request
|
||||
@@ -215,6 +236,42 @@ export async function searchSessions(ctx: Context) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!q) {
|
||||
const exactSessionId = await findLatestExactSessionIdWithProfile(task_id, profile)
|
||||
if (exactSessionId) {
|
||||
const sessionDetail = await getExactSessionDetailFromDbWithProfile(exactSessionId, profile)
|
||||
if (sessionDetail) {
|
||||
ctx.body = {
|
||||
results: [{
|
||||
id: exactSessionId,
|
||||
source: sessionDetail.source,
|
||||
title: sessionDetail.title,
|
||||
preview: sessionDetail.preview,
|
||||
model: sessionDetail.model,
|
||||
started_at: sessionDetail.started_at,
|
||||
ended_at: sessionDetail.ended_at,
|
||||
last_active: sessionDetail.last_active,
|
||||
message_count: sessionDetail.message_count,
|
||||
tool_call_count: sessionDetail.tool_call_count,
|
||||
input_tokens: sessionDetail.input_tokens,
|
||||
output_tokens: sessionDetail.output_tokens,
|
||||
cache_read_tokens: sessionDetail.cache_read_tokens,
|
||||
cache_write_tokens: sessionDetail.cache_write_tokens,
|
||||
reasoning_tokens: sessionDetail.reasoning_tokens,
|
||||
billing_provider: sessionDetail.billing_provider,
|
||||
estimated_cost_usd: sessionDetail.estimated_cost_usd,
|
||||
actual_cost_usd: sessionDetail.actual_cost_usd,
|
||||
cost_status: sessionDetail.cost_status,
|
||||
matched_message_id: null,
|
||||
snippet: sessionDetail.preview,
|
||||
rank: 0,
|
||||
}],
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const searchQuery = q || task_id
|
||||
const results = await searchSessionSummariesWithProfile(searchQuery, profile, undefined, 10)
|
||||
ctx.body = { results }
|
||||
|
||||
@@ -696,6 +696,139 @@ export async function getSessionDetailFromDbWithProfile(sessionId: string, profi
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExactSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise<HermesSessionDetailRow | null> {
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const dbPath = `${getProfileDir(profile)}/state.db`
|
||||
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
|
||||
try {
|
||||
const idx = loadAllSessions(db)
|
||||
const requested = idx.byId.get(sessionId) || null
|
||||
if (!requested) return null
|
||||
|
||||
const messageRows = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
session_id,
|
||||
role,
|
||||
content,
|
||||
tool_call_id,
|
||||
tool_calls,
|
||||
tool_name,
|
||||
timestamp,
|
||||
token_count,
|
||||
finish_reason,
|
||||
reasoning,
|
||||
reasoning_details,
|
||||
codex_reasoning_items,
|
||||
reasoning_content
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp, id
|
||||
`).all(sessionId) as Record<string, unknown>[]
|
||||
const messages = messageRows.map(mapMessageRow)
|
||||
return aggregateSessionDetail([requested], messages, sessionId)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function findLatestExactSessionIdWithProfile(
|
||||
query: string,
|
||||
profile: string,
|
||||
source?: string,
|
||||
): Promise<string | null> {
|
||||
if (!SQLITE_AVAILABLE) {
|
||||
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
|
||||
}
|
||||
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const dbPath = `${getProfileDir(profile)}/state.db`
|
||||
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
|
||||
const loweredQuery = trimmed.toLowerCase()
|
||||
const likePattern = buildLikePattern(loweredQuery)
|
||||
const kanbanPrompt = `work kanban task ${trimmed}`.toLowerCase()
|
||||
const taskJsonNeedle = `"task_id": "${trimmed}"`.toLowerCase()
|
||||
|
||||
try {
|
||||
const sourceClause = source ? 'AND s.source = ?' : ''
|
||||
const sourceParams = source ? [source] : []
|
||||
const exactPromptSql = `
|
||||
WITH base AS (
|
||||
SELECT
|
||||
${SESSION_SELECT},
|
||||
s.parent_session_id AS parent_session_id
|
||||
FROM sessions s
|
||||
WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%'
|
||||
${sourceClause}
|
||||
)
|
||||
SELECT base.id
|
||||
FROM base
|
||||
JOIN messages m ON m.session_id = base.id
|
||||
WHERE m.role = 'user'
|
||||
AND LOWER(TRIM(m.content)) = ?
|
||||
ORDER BY base.last_active DESC, m.timestamp DESC
|
||||
LIMIT 1
|
||||
`
|
||||
const exactPromptMatch = db.prepare(exactPromptSql).get(...sourceParams, kanbanPrompt) as Record<string, unknown> | undefined
|
||||
if (exactPromptMatch?.id) return String(exactPromptMatch.id)
|
||||
|
||||
const taskJsonSql = `
|
||||
WITH base AS (
|
||||
SELECT
|
||||
${SESSION_SELECT},
|
||||
s.parent_session_id AS parent_session_id
|
||||
FROM sessions s
|
||||
WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%'
|
||||
${sourceClause}
|
||||
)
|
||||
SELECT base.id
|
||||
FROM base
|
||||
JOIN messages m ON m.session_id = base.id
|
||||
WHERE LOWER(m.content) LIKE ? ESCAPE '\\'
|
||||
ORDER BY base.last_active DESC, m.timestamp DESC
|
||||
LIMIT 1
|
||||
`
|
||||
const taskJsonMatch = db.prepare(taskJsonSql).get(...sourceParams, buildLikePattern(taskJsonNeedle)) as Record<string, unknown> | undefined
|
||||
if (taskJsonMatch?.id) return String(taskJsonMatch.id)
|
||||
|
||||
const contentSql = `
|
||||
WITH base AS (
|
||||
SELECT
|
||||
${SESSION_SELECT},
|
||||
s.parent_session_id AS parent_session_id
|
||||
FROM sessions s
|
||||
WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%'
|
||||
${sourceClause}
|
||||
)
|
||||
SELECT base.id
|
||||
FROM base
|
||||
JOIN messages m ON m.session_id = base.id
|
||||
WHERE LOWER(m.content) LIKE ? ESCAPE '\\'
|
||||
ORDER BY base.last_active DESC, m.timestamp DESC
|
||||
LIMIT 1
|
||||
`
|
||||
const contentMatch = db.prepare(contentSql).get(...sourceParams, likePattern) as Record<string, unknown> | undefined
|
||||
if (contentMatch?.id) return String(contentMatch.id)
|
||||
|
||||
const titleSql = `
|
||||
SELECT s.id
|
||||
FROM sessions s
|
||||
WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%'
|
||||
${sourceClause}
|
||||
AND LOWER(COALESCE(s.title, '')) LIKE ? ESCAPE '\\'
|
||||
ORDER BY s.started_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
const titleMatch = db.prepare(titleSql).get(...sourceParams, likePattern) as Record<string, unknown> | undefined
|
||||
return titleMatch?.id ? String(titleMatch.id) : null
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export interface HermesUsageStats extends LocalUsageStats {
|
||||
cost: number
|
||||
total_api_calls: number
|
||||
|
||||
@@ -12,6 +12,8 @@ const mockGetStats = vi.hoisted(() => vi.fn())
|
||||
const mockGetAssignees = vi.hoisted(() => vi.fn())
|
||||
const mockSearchSessions = vi.hoisted(() => vi.fn())
|
||||
const mockGetSessionDetail = vi.hoisted(() => vi.fn())
|
||||
const mockGetExactSessionDetail = vi.hoisted(() => vi.fn())
|
||||
const mockFindLatestExactSessionId = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
@@ -36,6 +38,8 @@ vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({
|
||||
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
||||
searchSessionSummariesWithProfile: mockSearchSessions,
|
||||
getSessionDetailFromDbWithProfile: mockGetSessionDetail,
|
||||
getExactSessionDetailFromDbWithProfile: mockGetExactSessionDetail,
|
||||
findLatestExactSessionIdWithProfile: mockFindLatestExactSessionId,
|
||||
}))
|
||||
|
||||
import * as ctrl from '../../packages/server/src/controllers/hermes/kanban'
|
||||
@@ -71,8 +75,8 @@ describe('kanban controller', () => {
|
||||
comments: [],
|
||||
events: [],
|
||||
})
|
||||
mockSearchSessions.mockResolvedValue([{ id: 'session-1' }])
|
||||
mockGetSessionDetail.mockResolvedValue({
|
||||
mockFindLatestExactSessionId.mockResolvedValue('session-1')
|
||||
mockGetExactSessionDetail.mockResolvedValue({
|
||||
title: 'Session one',
|
||||
source: 'codex',
|
||||
model: 'gpt-5.5',
|
||||
@@ -84,11 +88,38 @@ describe('kanban controller', () => {
|
||||
const c = ctx({ params: { id: 'task-1' } })
|
||||
await ctrl.get(c)
|
||||
|
||||
expect(mockSearchSessions).toHaveBeenCalledWith('task-1', 'fresh', undefined, 5)
|
||||
expect(mockGetSessionDetail).toHaveBeenCalledWith('session-1', 'fresh')
|
||||
expect(mockFindLatestExactSessionId).toHaveBeenCalledWith('task-1', 'fresh')
|
||||
expect(mockGetExactSessionDetail).toHaveBeenCalledWith('session-1', 'fresh')
|
||||
expect(c.body.session).toMatchObject({ id: 'session-1', title: 'Session one' })
|
||||
})
|
||||
|
||||
it('prefers exact kanban-task session matches over later sessions that merely reference the task id', async () => {
|
||||
mockGetTask.mockResolvedValue({
|
||||
task: { id: 't_348bfaaf', status: 'done' },
|
||||
runs: [{ profile: 'default' }],
|
||||
comments: [],
|
||||
events: [],
|
||||
})
|
||||
mockFindLatestExactSessionId.mockResolvedValue('session_20260508_110903_58e664')
|
||||
mockGetExactSessionDetail.mockResolvedValue({
|
||||
title: 'work kanban task t_348bfaaf',
|
||||
source: 'codex',
|
||||
model: 'gpt-5.5',
|
||||
started_at: 1,
|
||||
ended_at: 2,
|
||||
messages: [{ id: 'm1', role: 'user', content: 'work kanban task t_348bfaaf', timestamp: 1 }],
|
||||
})
|
||||
|
||||
const c = ctx({ params: { id: 't_348bfaaf' } })
|
||||
await ctrl.get(c)
|
||||
|
||||
expect(c.body.session).toMatchObject({
|
||||
id: 'session_20260508_110903_58e664',
|
||||
title: 'work kanban task t_348bfaaf',
|
||||
})
|
||||
expect(c.body.session.messages[0].content).toBe('work kanban task t_348bfaaf')
|
||||
})
|
||||
|
||||
it('validates create/search/readArtifact requests', async () => {
|
||||
const createCtx = ctx({ request: { body: {} } })
|
||||
await ctrl.create(createCtx)
|
||||
@@ -113,6 +144,30 @@ describe('kanban controller', () => {
|
||||
mockGetStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} })
|
||||
mockGetAssignees.mockResolvedValue([{ name: 'alice' }])
|
||||
mockSearchSessions.mockResolvedValue([{ id: 'session-2' }])
|
||||
mockFindLatestExactSessionId.mockResolvedValue('session-2')
|
||||
mockGetExactSessionDetail.mockResolvedValue({
|
||||
id: 'session-2',
|
||||
source: 'codex',
|
||||
title: 'Matched session',
|
||||
preview: 'task-id matched',
|
||||
model: 'gpt-5.5',
|
||||
started_at: 100,
|
||||
ended_at: 101,
|
||||
last_active: 101,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
messages: [],
|
||||
thread_session_count: 1,
|
||||
})
|
||||
|
||||
const fileCtx = ctx({ query: { path: '/Users/tester/.hermes/kanban/workspaces/task/out.txt' } })
|
||||
await ctrl.readArtifact(fileCtx)
|
||||
@@ -152,5 +207,9 @@ describe('kanban controller', () => {
|
||||
const searchCtx = ctx({ query: { task_id: 'task-1', profile: 'alice', q: 'custom' } })
|
||||
await ctrl.searchSessions(searchCtx)
|
||||
expect(mockSearchSessions).toHaveBeenCalledWith('custom', 'alice', undefined, 10)
|
||||
|
||||
const exactSearchCtx = ctx({ query: { task_id: 'task-1', profile: 'alice' } })
|
||||
await ctrl.searchSessions(exactSearchCtx)
|
||||
expect(exactSearchCtx.body.results[0]).toMatchObject({ id: 'session-2', title: 'Matched session' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user