Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/__tests__/integration/chat/chat-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.',
Expand Down
13 changes: 7 additions & 6 deletions src/__tests__/integration/tui/ask-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
72 changes: 71 additions & 1 deletion src/__tests__/unit/core/chat-turn-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
10 changes: 7 additions & 3 deletions src/core/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
76 changes: 21 additions & 55 deletions src/core/chat/ordinary-turn.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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({
Expand All @@ -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),
);
}
},
});
Expand All @@ -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,
});
Expand Down
27 changes: 25 additions & 2 deletions src/core/chat/session-turn-preflight.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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),
);
}
Loading
Loading