From e55792acbb6f42eaccccc2cde34d488200e678dd Mon Sep 17 00:00:00 2001
From: ekko <152005280+EKKOLearnAI@users.noreply.github.com>
Date: Fri, 8 May 2026 13:53:40 +0800
Subject: [PATCH] [codex] fix kanban session matching (#538)
* fix kanban session matching
* tighten kanban task session lookup
* remove favicon svg
---
packages/client/public/favicon.svg | 1 -
.../server/src/controllers/hermes/kanban.ts | 69 ++++++++-
packages/server/src/db/hermes/sessions-db.ts | 133 ++++++++++++++++++
tests/server/kanban-controller.test.ts | 67 ++++++++-
4 files changed, 259 insertions(+), 11 deletions(-)
delete mode 100644 packages/client/public/favicon.svg
diff --git a/packages/client/public/favicon.svg b/packages/client/public/favicon.svg
deleted file mode 100644
index 6893eb13..00000000
--- a/packages/client/public/favicon.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/server/src/controllers/hermes/kanban.ts b/packages/server/src/controllers/hermes/kanban.ts
index bb1556ec..41a16de7 100644
--- a/packages/server/src/controllers/hermes/kanban.ts
+++ b/packages/server/src/controllers/hermes/kanban.ts
@@ -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 }
diff --git a/packages/server/src/db/hermes/sessions-db.ts b/packages/server/src/db/hermes/sessions-db.ts
index 942f7550..671c35d4 100644
--- a/packages/server/src/db/hermes/sessions-db.ts
+++ b/packages/server/src/db/hermes/sessions-db.ts
@@ -696,6 +696,139 @@ export async function getSessionDetailFromDbWithProfile(sessionId: string, profi
}
}
+export async function getExactSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise {
+ 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[]
+ const messages = messageRows.map(mapMessageRow)
+ return aggregateSessionDetail([requested], messages, sessionId)
+ } finally {
+ db.close()
+ }
+}
+
+export async function findLatestExactSessionIdWithProfile(
+ query: string,
+ profile: string,
+ source?: string,
+): Promise {
+ 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 | 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 | 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 | 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 | undefined
+ return titleMatch?.id ? String(titleMatch.id) : null
+ } finally {
+ db.close()
+ }
+}
+
export interface HermesUsageStats extends LocalUsageStats {
cost: number
total_api_calls: number
diff --git a/tests/server/kanban-controller.test.ts b/tests/server/kanban-controller.test.ts
index edb4b0d6..49a05253 100644
--- a/tests/server/kanban-controller.test.ts
+++ b/tests/server/kanban-controller.test.ts
@@ -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' })
})
})