From dd488c84dcba067004542fcd42003895c9cef777 Mon Sep 17 00:00:00 2001 From: Jay/Fienna Liang Date: Sun, 3 May 2026 10:30:14 +0800 Subject: [PATCH] Complete M11.5 chat turn cleanup --- .../integration/chat/chat-runtime.test.ts | 5 +- src/__tests__/integration/tui/ask-cli.test.ts | 13 ++-- .../unit/core/chat-turn-runtime.test.ts | 72 +++++++++++++++++- src/core/chat/README.md | 10 ++- src/core/chat/ordinary-turn.ts | 76 +++++-------------- src/core/chat/session-turn-preflight.ts | 27 ++++++- src/core/chat/turn-context.ts | 63 +++++++++++++++ src/core/chat/turn-execution.ts | 54 +++++++++++++ 8 files changed, 251 insertions(+), 69 deletions(-) create mode 100644 src/core/chat/turn-context.ts create mode 100644 src/core/chat/turn-execution.ts diff --git a/src/__tests__/integration/chat/chat-runtime.test.ts b/src/__tests__/integration/chat/chat-runtime.test.ts index b88e7cb..af185a6 100644 --- a/src/__tests__/integration/chat/chat-runtime.test.ts +++ b/src/__tests__/integration/chat/chat-runtime.test.ts @@ -8,6 +8,7 @@ import { resolveApiKeyForModel, resolveChatRuntimeConfig, resolveProviderCredent import { createLogger } from '../../../core/utils/logger.js'; import type { LlmAdapter, RunResult, ToolCall, ToolDefinition } from '../../../index.js'; import { setStoredProviderCredential } from '../../../core/auth/provider-credentials.js'; +import * as agentLoopModule from '../../../core/runtime/agent-loop.js'; import { continueChatPrompt, createControlPlaneChatSession, readChatSessionDetail, submitChatPrompt } from '../../../server/features/control-plane/services/chat-sessions.js'; describe('resolveChatRuntimeConfig', () => { @@ -297,7 +298,7 @@ describe('executeAgentTurn final message persistence', () => { ], }; - const runAgentLoopSpy = vi.spyOn(await import('../../../index.js'), 'runAgentLoop').mockResolvedValue(result as never); + const runAgentLoopSpy = vi.spyOn(agentLoopModule, 'runAgentLoop').mockResolvedValue(result as never); await executeAgentTurn({ prompt, @@ -454,7 +455,7 @@ describe('control-plane shared chat runtime integration', () => { apiKeyPresent: true, }); - const loopSpy = vi.spyOn(await import('../../../index.js'), 'runAgentLoop') + const loopSpy = vi.spyOn(agentLoopModule, 'runAgentLoop') .mockResolvedValueOnce({ outcome: 'done', summary: 'First turn done.', diff --git a/src/__tests__/integration/tui/ask-cli.test.ts b/src/__tests__/integration/tui/ask-cli.test.ts index 5b3a3bd..29eb3da 100644 --- a/src/__tests__/integration/tui/ask-cli.test.ts +++ b/src/__tests__/integration/tui/ask-cli.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { runAskCli } from '../../../cli/ask.js'; import { createChatSession, readChatSession, readChatSessionCatalog, saveChatSessions } from '../../../core/chat/storage.js'; import type { ChatSession } from '../../../core/chat/types.js'; +import * as agentLoopModule from '../../../core/runtime/agent-loop.js'; import type { ResolvedRuntimeHost } from '../../../core/runtime/runtime-hosts.js'; import type { RunResult } from '../../../index.js'; import { createHeddleServerApp } from '../../../server/app.js'; @@ -39,7 +40,7 @@ describe('runAskCli', () => { { role: 'assistant', content: 'Stateless answer.' }, ], }; - const runAgentLoopSpy = vi.spyOn(await import('../../../index.js'), 'runAgentLoop').mockResolvedValue(result as never); + const runAgentLoopSpy = vi.spyOn(agentLoopModule, 'runAgentLoop').mockResolvedValue(result as never); await runAskCli('what is this project', { workspaceRoot, @@ -75,7 +76,7 @@ describe('runAskCli', () => { { role: 'assistant', content: 'Session-backed answer.' }, ], }; - vi.spyOn(await import('../../../index.js'), 'runAgentLoop').mockResolvedValue(result as never); + vi.spyOn(agentLoopModule, 'runAgentLoop').mockResolvedValue(result as never); await runAskCli('inspect the repository', { workspaceRoot, @@ -138,7 +139,7 @@ describe('runAskCli', () => { { role: 'assistant', content: 'Follow-up answer.' }, ], }; - const runAgentLoopSpy = vi.spyOn(await import('../../../index.js'), 'runAgentLoop').mockImplementation(async (options) => { + const runAgentLoopSpy = vi.spyOn(agentLoopModule, 'runAgentLoop').mockImplementation(async (options) => { expect(options.history).toEqual(existingSession.history); expect(options.goal).toBe('follow up question'); return result as never; @@ -248,7 +249,7 @@ describe('runAskCli', () => { { role: 'assistant', content: 'Follow-up answer.' }, ], }; - const runAgentLoopSpy = vi.spyOn(await import('../../../index.js'), 'runAgentLoop').mockImplementation(async (options) => { + const runAgentLoopSpy = vi.spyOn(agentLoopModule, 'runAgentLoop').mockImplementation(async (options) => { expect(options.history).toEqual(compactedHistory); return result as never; }); @@ -286,7 +287,7 @@ describe('runAskCli', () => { { role: 'assistant', content: 'Remote stateless answer.' }, ], }; - vi.spyOn(await import('../../../index.js'), 'runAgentLoop').mockResolvedValue(result as never); + vi.spyOn(agentLoopModule, 'runAgentLoop').mockResolvedValue(result as never); const server = createHeddleServerApp({ workspaceRoot, stateRoot }).listen(0, '127.0.0.1'); await onceListening(server); @@ -339,7 +340,7 @@ describe('runAskCli', () => { { role: 'assistant', content: 'Remote session answer.' }, ], }; - vi.spyOn(await import('../../../index.js'), 'runAgentLoop').mockResolvedValue(result as never); + vi.spyOn(agentLoopModule, 'runAgentLoop').mockResolvedValue(result as never); const server = createHeddleServerApp({ workspaceRoot, stateRoot }).listen(0, '127.0.0.1'); await onceListening(server); diff --git a/src/__tests__/unit/core/chat-turn-runtime.test.ts b/src/__tests__/unit/core/chat-turn-runtime.test.ts index ade4db1..c16daaa 100644 --- a/src/__tests__/unit/core/chat-turn-runtime.test.ts +++ b/src/__tests__/unit/core/chat-turn-runtime.test.ts @@ -3,7 +3,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { setStoredProviderCredential } from '../../../core/auth/provider-credentials.js'; -import { createChatSession, saveChatSessions } from '../../../core/chat/storage.js'; +import { createChatSession, loadChatSessions, saveChatSessions } from '../../../core/chat/storage.js'; +import { persistPreflightCompactionRunningSeed } from '../../../core/chat/session-turn-preflight.js'; +import { prepareOrdinaryChatTurnContext } from '../../../core/chat/turn-context.js'; import { loadChatTurnSession } from '../../../core/chat/turn-session.js'; import { resolveChatTurnModel, resolveChatTurnRuntime } from '../../../core/chat/turn-runtime.js'; import { createChatTurnTools, listChatTurnToolNames } from '../../../core/chat/turn-tools.js'; @@ -146,4 +148,72 @@ describe('chat turn preparation modules', () => { 'run_shell_mutate', ]); }); + + it('prepares ordinary turn context with runtime, tools, and default lease owner', () => { + const root = mkdtempSync(join(tmpdir(), 'heddle-turn-context-')); + const sessionStoragePath = join(root, '.heddle', 'chat-sessions.catalog.json'); + const session = createChatSession({ + id: 'session-1', + name: 'Session 1', + apiKeyPresent: true, + model: 'gpt-5.4', + }); + saveChatSessions(sessionStoragePath, [session]); + + const context = prepareOrdinaryChatTurnContext({ + workspaceRoot: root, + stateRoot: join(root, '.heddle'), + sessionStoragePath, + sessionId: 'session-1', + apiKey: 'explicit-key', + }); + + expect(context.session.id).toBe('session-1'); + expect(context.runtime.model).toBe('gpt-5.4'); + expect(context.toolNames).toContain('read_file'); + expect(context.toolNames).toContain('update_plan'); + expect(context.leaseOwner).toMatchObject({ + ownerKind: 'ask', + clientLabel: 'another Heddle client', + }); + }); + + it('persists the preflight compaction-running context for the leased session', () => { + const root = mkdtempSync(join(tmpdir(), 'heddle-turn-preflight-seed-')); + const sessionStoragePath = join(root, '.heddle', 'chat-sessions.catalog.json'); + const session = createChatSession({ + id: 'session-1', + name: 'Session 1', + apiKeyPresent: true, + model: 'gpt-5.4', + }); + const leasedSession = { + ...session, + history: [ + { role: 'user' as const, content: 'Earlier prompt' }, + { role: 'assistant' as const, content: 'Earlier answer' }, + ], + lease: { + ownerId: 'owner-1', + ownerKind: 'ask' as const, + clientLabel: 'test client', + acquiredAt: '2026-05-03T00:00:00.000Z', + lastSeenAt: '2026-05-03T00:00:00.000Z', + }, + }; + saveChatSessions(sessionStoragePath, [leasedSession]); + + persistPreflightCompactionRunningSeed({ + sessionStoragePath, + sessions: [leasedSession], + sessionId: 'session-1', + leasedSession, + archivePath: '.heddle/chat-sessions/session-1/archives/archive-1.jsonl', + }); + + const nextSession = loadChatSessions(sessionStoragePath, true)[0]; + expect(nextSession?.context?.compactionStatus).toBe('running'); + expect(nextSession?.context?.lastArchivePath).toBe('.heddle/chat-sessions/session-1/archives/archive-1.jsonl'); + expect(nextSession?.lease).toEqual(leasedSession.lease); + }); }); diff --git a/src/core/chat/README.md b/src/core/chat/README.md index 9ec5101..60fee67 100644 --- a/src/core/chat/README.md +++ b/src/core/chat/README.md @@ -27,6 +27,10 @@ maintenance, traces, and host ports. ## Public Entry Points - `ordinary-turn.ts`: current conversation-turn harness. +- `turn-context.ts`: ordinary turn context preparation, including session, + runtime, tool bundle, tool names, and lease owner. +- `turn-execution.ts`: run-loop option assembly and host approval bridge for an + ordinary chat turn. - `turn-session.ts`: session loading for ordinary chat turns. - `turn-runtime.ts`: model, credential, LLM, memory, and system-context preparation for ordinary chat turns. @@ -46,7 +50,7 @@ maintenance, traces, and host ports. ## Extension Points -- Add host integration through `ChatTurnHostPort` or future conversation activity +- Add host integration through `ChatTurnHostPort` or conversation activity projections. - Add conversation-turn phases as named services before introducing middleware. - Add compaction behavior through compaction helpers and tests; keep host UI @@ -72,8 +76,8 @@ maintenance, traces, and host ports. ## Notes For Coding Agents -- Treat `ordinary-turn.ts` as the current conversation-engine seam. Shrink it by - extracting named phases; do not move unrelated behavior into it. +- Treat `ordinary-turn.ts` as the current conversation-engine seam. Keep lease + cleanup visible there, but put phase-specific mechanics in named turn modules. - Keep host-specific wording in adapters. Core chat should emit semantics and persist durable evidence. - Do not import from TUI, web, or server code. diff --git a/src/core/chat/ordinary-turn.ts b/src/core/chat/ordinary-turn.ts index 6af67eb..ac7dc99 100644 --- a/src/core/chat/ordinary-turn.ts +++ b/src/core/chat/ordinary-turn.ts @@ -1,19 +1,15 @@ -import { runAgentLoop } from '../../index.js'; import type { RunAgentLoopOptions } from '../runtime/agent-loop.js'; -import type { ToolCall, ToolDefinition } from '../types.js'; import type { ToolApprovalPolicy } from '../approvals/types.js'; import type { TraceSummarizerRegistry } from '../observability/trace-summarizers.js'; -import { buildCompactionRunningContext } from './compaction.js'; import { buildConversationMessages } from './conversation-lines.js'; import { releaseSessionLease, type ChatSessionLeaseOwner } from './session-lease.js'; -import { prepareChatSessionTurn } from './session-turn-preflight.js'; +import { persistPreflightCompactionRunningSeed, prepareChatSessionTurn } from './session-turn-preflight.js'; import { loadChatSessions, saveChatSessions, touchSession } from './storage.js'; import type { ChatTurnHostPort } from './turn-host.js'; +import { prepareOrdinaryChatTurnContext } from './turn-context.js'; +import { runOrdinaryChatTurnLoop } from './turn-execution.js'; import { runInlineTurnMemoryMaintenance, scheduleBackgroundTurnMemoryMaintenance } from './turn-memory-maintenance.js'; import { persistCompletedChatTurn } from './turn-persistence.js'; -import { loadChatTurnSession } from './turn-session.js'; -import { resolveChatTurnRuntime } from './turn-runtime.js'; -import { createChatTurnTools, listChatTurnToolNames } from './turn-tools.js'; export type ExecuteOrdinaryChatTurnArgs = { workspaceRoot: string; @@ -38,33 +34,18 @@ export type ExecuteOrdinaryChatTurnArgs = { }; export async function executeOrdinaryChatTurn(args: ExecuteOrdinaryChatTurnArgs) { - const { sessions, session } = loadChatTurnSession({ + const context = prepareOrdinaryChatTurnContext({ + workspaceRoot: args.workspaceRoot, + stateRoot: args.stateRoot, sessionStoragePath: args.sessionStoragePath, sessionId: args.sessionId, - }); - const runtime = resolveChatTurnRuntime({ - stateRoot: args.stateRoot, - sessionModel: session.model, apiKey: args.apiKey, preferApiKey: args.preferApiKey, credentialStorePath: args.credentialStorePath, systemContext: args.systemContext, + leaseOwner: args.leaseOwner, }); - const tools = createChatTurnTools({ - model: runtime.model, - apiKey: runtime.apiKey, - providerCredentialSource: runtime.providerCredentialSource, - credentialStorePath: args.credentialStorePath, - workspaceRoot: args.workspaceRoot, - memoryDir: runtime.memoryDir, - }); - const toolNames = listChatTurnToolNames(tools); - - const leaseOwner = args.leaseOwner ?? { - ownerKind: 'ask' as const, - ownerId: `submit-${process.pid}`, - clientLabel: 'another Heddle client', - }; + const { sessions, session, runtime, tools, toolNames, leaseOwner } = context; try { const preflight = await prepareChatSessionTurn({ @@ -82,20 +63,13 @@ export async function executeOrdinaryChatTurn(args: ExecuteOrdinaryChatTurnArgs) args.onCompactionStatus?.(event); args.host?.compaction?.onPreflightCompactionStatus?.(event); if (event.status === 'running' && leasedSession) { - const compactionSeed = touchSession({ - ...leasedSession, - context: buildCompactionRunningContext({ - history: leasedSession.history, - previous: leasedSession.context, - archiveCount: leasedSession.archives?.length, - currentSummaryPath: leasedSession.context?.currentSummaryPath, - lastArchivePath: event.archivePath, - }), + persistPreflightCompactionRunningSeed({ + sessionStoragePath: args.sessionStoragePath, + sessions, + sessionId: session.id, + leasedSession, + archivePath: event.archivePath, }); - saveChatSessions( - args.sessionStoragePath, - sessions.map((candidate) => candidate.id === session.id ? compactionSeed : candidate), - ); } }, }); @@ -114,25 +88,17 @@ export async function executeOrdinaryChatTurn(args: ExecuteOrdinaryChatTurnArgs) sessions.map((candidate) => candidate.id === session.id ? preflightSession : candidate), ); - const result = await runAgentLoop({ - goal: args.prompt, - model: runtime.model, - apiKey: runtime.apiKey, + const result = await runOrdinaryChatTurnLoop({ + prompt: args.prompt, workspaceRoot: args.workspaceRoot, - stateDir: args.stateRoot, - memoryDir: runtime.memoryDir, - llm: runtime.llm, + stateRoot: args.stateRoot, + runtime, tools, - includeDefaultTools: false, - history: preflightSession.history, - systemContext: runtime.systemContext, + session: preflightSession, + host: args.host, + approvalPolicies: args.approvalPolicies, onAssistantStream: args.onAssistantStream, onTraceEvent: args.onTraceEvent, - onEvent: args.host?.events?.onAgentLoopEvent, - approvalPolicies: args.approvalPolicies, - approveToolCall: args.host?.approvals?.requestToolApproval ? - ((call: ToolCall, tool: ToolDefinition) => args.host?.approvals?.requestToolApproval?.({ call, tool }) ?? Promise.resolve({ approved: false, reason: 'Missing approval port.' })) - : undefined, shouldStop: args.shouldStop, abortSignal: args.abortSignal, }); diff --git a/src/core/chat/session-turn-preflight.ts b/src/core/chat/session-turn-preflight.ts index 37c0220..9d0d6c4 100644 --- a/src/core/chat/session-turn-preflight.ts +++ b/src/core/chat/session-turn-preflight.ts @@ -1,8 +1,8 @@ import type { ChatMessage } from '../llm/types.js'; import { buildConversationMessages } from './conversation-lines.js'; -import { compactChatHistoryWithArchive } from './compaction.js'; +import { buildCompactionRunningContext, compactChatHistoryWithArchive } from './compaction.js'; import { acquireSessionLease, getSessionLeaseConflict, type ChatSessionLeaseOwner } from './session-lease.js'; -import { readChatSession, touchSession } from './storage.js'; +import { readChatSession, saveChatSessions, touchSession } from './storage.js'; import type { ChatArchiveRecord, ChatContextStats, ChatSession } from './types.js'; export type ChatTurnPreflightCompactionStatus = { @@ -81,3 +81,26 @@ export async function prepareChatSessionTurn(args: PrepareChatSessionTurnArgs): archives: preflightCompacted.archives, }; } + +export function persistPreflightCompactionRunningSeed(args: { + sessionStoragePath: string; + sessions: ChatSession[]; + sessionId: string; + leasedSession: ChatSession; + archivePath?: string; +}) { + const compactionSeed = touchSession({ + ...args.leasedSession, + context: buildCompactionRunningContext({ + history: args.leasedSession.history, + previous: args.leasedSession.context, + archiveCount: args.leasedSession.archives?.length, + currentSummaryPath: args.leasedSession.context?.currentSummaryPath, + lastArchivePath: args.archivePath, + }), + }); + saveChatSessions( + args.sessionStoragePath, + args.sessions.map((candidate) => candidate.id === args.sessionId ? compactionSeed : candidate), + ); +} diff --git a/src/core/chat/turn-context.ts b/src/core/chat/turn-context.ts new file mode 100644 index 0000000..7d68672 --- /dev/null +++ b/src/core/chat/turn-context.ts @@ -0,0 +1,63 @@ +import type { ToolDefinition } from '../types.js'; +import type { ChatSessionLeaseOwner } from './session-lease.js'; +import type { ChatSession } from './types.js'; +import { loadChatTurnSession } from './turn-session.js'; +import { resolveChatTurnRuntime, type ChatTurnRuntime } from './turn-runtime.js'; +import { createChatTurnTools, listChatTurnToolNames } from './turn-tools.js'; + +export type PrepareOrdinaryChatTurnContextArgs = { + workspaceRoot: string; + stateRoot: string; + sessionStoragePath: string; + sessionId: string; + apiKey?: string; + preferApiKey?: boolean; + credentialStorePath?: string; + systemContext?: string; + leaseOwner?: ChatSessionLeaseOwner; +}; + +export type OrdinaryChatTurnContext = { + sessions: ChatSession[]; + session: ChatSession; + runtime: ChatTurnRuntime; + tools: ToolDefinition[]; + toolNames: string[]; + leaseOwner: ChatSessionLeaseOwner; +}; + +export function prepareOrdinaryChatTurnContext(args: PrepareOrdinaryChatTurnContextArgs): OrdinaryChatTurnContext { + const { sessions, session } = loadChatTurnSession({ + sessionStoragePath: args.sessionStoragePath, + sessionId: args.sessionId, + }); + const runtime = resolveChatTurnRuntime({ + stateRoot: args.stateRoot, + sessionModel: session.model, + apiKey: args.apiKey, + preferApiKey: args.preferApiKey, + credentialStorePath: args.credentialStorePath, + systemContext: args.systemContext, + }); + const tools = createChatTurnTools({ + model: runtime.model, + apiKey: runtime.apiKey, + providerCredentialSource: runtime.providerCredentialSource, + credentialStorePath: args.credentialStorePath, + workspaceRoot: args.workspaceRoot, + memoryDir: runtime.memoryDir, + }); + + return { + sessions, + session, + runtime, + tools, + toolNames: listChatTurnToolNames(tools), + leaseOwner: args.leaseOwner ?? { + ownerKind: 'ask', + ownerId: `submit-${process.pid}`, + clientLabel: 'another Heddle client', + }, + }; +} diff --git a/src/core/chat/turn-execution.ts b/src/core/chat/turn-execution.ts new file mode 100644 index 0000000..d12ccc9 --- /dev/null +++ b/src/core/chat/turn-execution.ts @@ -0,0 +1,54 @@ +import type { ToolApprovalPolicy } from '../approvals/types.js'; +import { runAgentLoop, type RunAgentLoopOptions } from '../runtime/agent-loop.js'; +import type { ToolCall, ToolDefinition } from '../types.js'; +import type { ChatSession } from './types.js'; +import type { ChatTurnHostPort } from './turn-host.js'; +import type { ChatTurnRuntime } from './turn-runtime.js'; + +export type RunOrdinaryChatTurnLoopArgs = { + prompt: string; + workspaceRoot: string; + stateRoot: string; + runtime: ChatTurnRuntime; + tools: ToolDefinition[]; + session: ChatSession; + host?: ChatTurnHostPort; + approvalPolicies?: ToolApprovalPolicy[]; + onAssistantStream?: RunAgentLoopOptions['onAssistantStream']; + onTraceEvent?: RunAgentLoopOptions['onTraceEvent']; + shouldStop?: RunAgentLoopOptions['shouldStop']; + abortSignal?: AbortSignal; +}; + +export function runOrdinaryChatTurnLoop(args: RunOrdinaryChatTurnLoopArgs) { + return runAgentLoop({ + goal: args.prompt, + model: args.runtime.model, + apiKey: args.runtime.apiKey, + workspaceRoot: args.workspaceRoot, + stateDir: args.stateRoot, + memoryDir: args.runtime.memoryDir, + llm: args.runtime.llm, + tools: args.tools, + includeDefaultTools: false, + history: args.session.history, + systemContext: args.runtime.systemContext, + onAssistantStream: args.onAssistantStream, + onTraceEvent: args.onTraceEvent, + onEvent: args.host?.events?.onAgentLoopEvent, + approvalPolicies: args.approvalPolicies, + approveToolCall: createHostToolApprovalBridge(args.host), + shouldStop: args.shouldStop, + abortSignal: args.abortSignal, + }); +} + +function createHostToolApprovalBridge(host: ChatTurnHostPort | undefined): RunAgentLoopOptions['approveToolCall'] { + if (!host?.approvals?.requestToolApproval) { + return undefined; + } + + return (call: ToolCall, tool: ToolDefinition) => ( + host.approvals?.requestToolApproval?.({ call, tool }) ?? Promise.resolve({ approved: false, reason: 'Missing approval port.' }) + ); +}