Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f0749ed
refactor(ai): introduce turn request builder module
ibetitsmike Aug 29, 2026
28ed2ce
refactor(init): inject initialization manager directly
ibetitsmike Aug 29, 2026
2d387da
refactor(stream): own pre-start lifecycle in stream manager
ibetitsmike Aug 29, 2026
00b9ee0
test(stream): cover pre-start lifecycle ownership
ibetitsmike Aug 29, 2026
d281479
refactor(ai): extract turn request builder orchestration
ibetitsmike Aug 29, 2026
1fabe8f
refactor(ai): share model attempt preparation
ibetitsmike Aug 29, 2026
a5bdc64
fix(ai): preserve builder early outcomes
ibetitsmike Aug 29, 2026
ad84e0b
🤖 refactor: narrow AgentSession AI dependency
ibetitsmike Aug 29, 2026
0c4a00d
refactor(ai): share per-model request preparation
ibetitsmike Aug 29, 2026
2b50ba6
fix(ai): preserve startup breadcrumbs
ibetitsmike Aug 29, 2026
1e81ef8
refactor(stream): centralize startup lifecycle ownership
ibetitsmike Aug 29, 2026
574fbd5
refactor(stream): migrate lifecycle consumers to engine
ibetitsmike Aug 29, 2026
94a6ef9
refactor(ai): consolidate turn preparation dependencies
ibetitsmike Aug 29, 2026
bafc4b8
🤖 tests: move preparation coverage to request builder
ibetitsmike Aug 29, 2026
7289760
refactor(session): route lifecycle through turn engine
ibetitsmike Aug 29, 2026
b5c0d3e
fix(ai): read OAuth services live from turn bindings
ibetitsmike Aug 29, 2026
bc963b6
tests: satisfy the engine lifecycle seam in hand-rolled AI mocks
ibetitsmike Aug 29, 2026
876d4f0
refactor: flatten builder access paths and prune slop from the extrac…
ibetitsmike Aug 29, 2026
af26364
tests: read init waits from the context initStateManager in router tests
ibetitsmike Aug 29, 2026
64699b1
tests: give multi-project and task harness mocks engine lifecycle access
ibetitsmike Aug 29, 2026
f26e69c
refactor(stream): migrate relocated service callers off the deleted w…
ibetitsmike Aug 30, 2026
a2aa419
🤖 fix(diagnostics): scope prepareMessagesForProviderMs to the message…
ibetitsmike Aug 31, 2026
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
9 changes: 6 additions & 3 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,8 @@ async function main(): Promise<number> {
workspaceService,
workspaceGoalService,
idleDispatcher,
streamManager,
turnRequestBuilderBindings,
} = createCoreServices({
config,
policyService,
Expand All @@ -675,7 +677,7 @@ async function main(): Promise<number> {
// Codex OAuth explicitly to ensure Codex-routed OpenAI requests can load/refresh
// OAuth tokens from providers.jsonc.
const codexOauthService = new CodexOauthService(config, providerService);
aiService.setCodexOauthService(codexOauthService);
turnRequestBuilderBindings.codexOauthService = codexOauthService;
// Same for Coder OAuth: coder:* models need per-request token loading/refresh.
// Bind it to the REAL config (not the ephemeral tempDir copy): Coder rotates
// the refresh token on every use, so persisting rotations only to tempDir
Expand All @@ -690,7 +692,7 @@ async function main(): Promise<number> {
// token refreshes/issuer checks, and denied providers fail closed.
policyService
);
aiService.setCoderOauthService(coderOauthService);
turnRequestBuilderBindings.coderOauthService = coderOauthService;

// CLI-only exit code control: allows agent to set the process exit code
// Useful for CI workflows where the agent should block merge on failure
Expand All @@ -715,13 +717,14 @@ async function main(): Promise<number> {
return { success: true, exit_code };
},
});
aiService.setExtraTools({ set_exit_code: setExitCodeTool });
turnRequestBuilderBindings.extraTools = { set_exit_code: setExitCodeTool };

const session = new AgentSession({
workspaceId,
config,
historyService,
aiService,
streamManager,
initStateManager,
backgroundProcessManager,
workspaceGoalService,
Expand Down
5 changes: 3 additions & 2 deletions src/cli/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ async function createWorkflowContext(options: {
mcpConfig: realConfig,
});
codexOauthService = new CodexOauthService(config, services.providerService);
services.aiService.setCodexOauthService(codexOauthService);
services.turnRequestBuilderBindings.codexOauthService = codexOauthService;
// Bind Coder OAuth to the REAL config (not the ephemeral tempDir copy):
// Coder rotates the refresh token on every use, so persisting rotations
// only to tempDir would strand ~/.xum/providers.jsonc with a consumed
Expand All @@ -382,7 +382,7 @@ async function createWorkflowContext(options: {
// for token refreshes/issuer checks, and denied providers fail closed.
policyService
);
services.aiService.setCoderOauthService(coderOauthService);
services.turnRequestBuilderBindings.coderOauthService = coderOauthService;

// Const capture: `services` is a `let`, so the deferred sanitize closure
// below would lose TypeScript's definite-assignment narrowing.
Expand All @@ -392,6 +392,7 @@ async function createWorkflowContext(options: {
config,
historyService: services.historyService,
aiService: services.aiService,
streamManager: services.streamManager,
initStateManager: services.initStateManager,
backgroundProcessManager: services.backgroundProcessManager,
workspaceGoalService: services.workspaceGoalService,
Expand Down
4 changes: 4 additions & 0 deletions src/node/orpc/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { IncomingHttpHeaders } from "http";
import type { Config } from "@/node/config";
import type { AIService } from "@/node/services/aiService";
import type { HistoryService } from "@/node/services/historyService";
import type { InitStateManager } from "@/node/services/initStateManager";
import type { StreamManager } from "@/node/services/streamManager";
import type { ProjectService } from "@/node/services/projectService";
import type { WorkspaceService } from "@/node/services/workspaceService";
import type { MuxGatewayOauthService } from "@/node/services/muxGatewayOauthService";
Expand Down Expand Up @@ -56,6 +58,8 @@ export interface ORPCContext {
config: Config;
aiService: AIService;
historyService: HistoryService;
streamManager: StreamManager;
initStateManager: InitStateManager;
projectService: ProjectService;
workspaceService: WorkspaceService;
taskService: TaskService;
Expand Down
4 changes: 2 additions & 2 deletions src/node/orpc/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ describe("router agent skill routes", () => {

const context = {
config: new Config(tempDir),
initStateManager: { waitForInit: mock(async () => undefined) },
aiService: {
waitForInit: mock(async () => undefined),
resolveXumToolScopeForWorkspace: mock(() => ({
type: "project",
xumHome: tempDir,
Expand Down Expand Up @@ -123,8 +123,8 @@ describe("router agent skill routes", () => {

const context = {
config: new Config(tempDir),
initStateManager: { waitForInit: mock(async () => undefined) },
aiService: {
waitForInit: mock(async () => undefined),
resolveXumToolScopeForWorkspace: mock(() => ({
type: "project",
xumHome: tempDir,
Expand Down
6 changes: 3 additions & 3 deletions src/node/services/agentDefinitions/agentDefinitionsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -942,7 +942,7 @@ export async function resolveAgentFrontmatter(

export type AgentDefinitionsContext = Pick<
ORPCContext,
"config" | "aiService" | "experimentsService"
"config" | "aiService" | "experimentsService" | "initStateManager"
>;

export async function resolveAgentDiscoveryContext(
Expand Down Expand Up @@ -980,7 +980,7 @@ export async function listAgentDefinitions(
includeDisabled?: boolean;
}
) {
if (input.workspaceId) await context.aiService.waitForInit(input.workspaceId);
if (input.workspaceId) await context.initStateManager.waitForInit(input.workspaceId);
const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input);
const includeAgentPlugins = context.experimentsService.isExperimentEnabled(
EXPERIMENT_IDS.AGENT_PLUGINS
Expand Down Expand Up @@ -1041,7 +1041,7 @@ export async function getAgentDefinition(
agentId: AgentId;
}
) {
if (input.workspaceId) await context.aiService.waitForInit(input.workspaceId);
if (input.workspaceId) await context.initStateManager.waitForInit(input.workspaceId);
const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input);
return readAgentDefinition(runtime, discoveryPath, input.agentId, {
includeAgentPlugins: context.experimentsService.isExperimentEnabled(
Expand Down
6 changes: 3 additions & 3 deletions src/node/services/agentPlugins/workspacePluginOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export async function listWorkspaceMcpPrompts(
workspaceId: string,
signal?: AbortSignal
) {
await context.aiService.waitForInit(workspaceId, signal);
await context.initStateManager.waitForInit(workspaceId, signal);
const metadataResult = await context.aiService.getWorkspaceMetadata(workspaceId);
if (!metadataResult.success) throw new Error(metadataResult.error);
const metadata = metadataResult.data;
Expand Down Expand Up @@ -152,7 +152,7 @@ export async function listWorkspacePluginSlashCommands(
signal?: AbortSignal
) {
if (!context.experimentsService.isExperimentEnabled(EXPERIMENT_IDS.AGENT_PLUGINS)) return [];
await context.aiService.waitForInit(workspaceId, signal);
await context.initStateManager.waitForInit(workspaceId, signal);
const metadataResult = await context.aiService.getWorkspaceMetadata(workspaceId);
if (!metadataResult.success) throw new Error(metadataResult.error);
const metadata = metadataResult.data;
Expand All @@ -173,7 +173,7 @@ export async function getWorkspacePluginComposition(
workspaceId: string,
signal?: AbortSignal
) {
await context.aiService.waitForInit(workspaceId, signal);
await context.initStateManager.waitForInit(workspaceId, signal);
const metadataResult = await context.aiService.getWorkspaceMetadata(workspaceId);
if (!metadataResult.success) throw new Error(metadataResult.error);
const metadata = metadataResult.data;
Expand Down
2 changes: 2 additions & 0 deletions src/node/services/agentSession.admissionGates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { SendMessageError } from "@/common/types/errors";
import { createMuxMessage } from "@/common/types/message";
import { Ok } from "@/common/types/result";
import { AgentSession, CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE } from "./agentSession";
import { createStreamLifecycleMocks } from "./agentSession.testHarness";
import { createTestHistoryService } from "./testHistoryService";

const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest";
Expand All @@ -32,6 +33,7 @@ describe("AgentSession.sendMessage (admission gates)", () => {

const streamMessage = mock(() => Promise.resolve(Ok(undefined)));
const aiService = Object.assign(new EventEmitter(), {
...createStreamLifecycleMocks(),
isStreaming: mock((_workspaceId: string) => false),
stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))),
streamMessage: streamMessage as unknown as AIService["streamMessage"],
Expand Down
10 changes: 9 additions & 1 deletion src/node/services/agentSession.autoCompaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import type { BackgroundProcessManager } from "@/node/services/backgroundProcess
import type { InitStateManager } from "@/node/services/initStateManager";
import { AgentSession } from "./agentSession";
import type { CompactionMonitor } from "./compactionMonitor";
import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness";
import {
createAgentSessionHarness,
createStartedTurnHandle,
createStreamLifecycleMocks,
} from "./agentSession.testHarness";
import { createTestHistoryService } from "./testHistoryService";

describe("AgentSession on-send auto-compaction snapshot deferral", () => {
Expand Down Expand Up @@ -824,6 +828,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
});

const aiService = Object.assign(aiEmitter, {
...createStreamLifecycleMocks(),
isStreaming: mock((_workspaceId: string) => false),
stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))),
streamMessage: streamMessage as unknown as (
Expand Down Expand Up @@ -944,6 +949,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
Promise.resolve(Ok(createStartedTurnHandle()))
);
const aiService = Object.assign(aiEmitter, {
...createStreamLifecycleMocks(),
isStreaming: mock((_workspaceId: string) => false),
stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))),
streamMessage: streamMessage as unknown as (
Expand Down Expand Up @@ -1053,6 +1059,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
});

const aiService = Object.assign(aiEmitter, {
...createStreamLifecycleMocks(),
isStreaming: mock((_workspaceId: string) => false),
stopStream,
streamMessage: streamMessage as unknown as (
Expand Down Expand Up @@ -1201,6 +1208,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
});

const aiService = Object.assign(aiEmitter, {
...createStreamLifecycleMocks(),
isStreaming: mock((_workspaceId: string) => false),
stopStream,
streamMessage: streamMessage as unknown as (
Expand Down
2 changes: 2 additions & 0 deletions src/node/services/agentSession.continueMessageAgentId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { CompactionFollowUpRequest, MuxMessage } from "@/common/types/messa
import type { FilePart, SendMessageOptions } from "@/common/orpc/types";
import type { Config } from "@/node/config";
import { AgentSession } from "./agentSession";
import { createStreamLifecycleMocks } from "./agentSession.testHarness";
import type { AIService } from "./aiService";
import type { BackgroundProcessManager } from "./backgroundProcessManager";
import type { InitStateManager } from "./initStateManager";
Expand Down Expand Up @@ -104,6 +105,7 @@ function createAiService(): AIService {
off() {
return this;
},
...createStreamLifecycleMocks(),
isStreaming: () => false,
stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })),
} as unknown as AIService;
Expand Down
8 changes: 7 additions & 1 deletion src/node/services/agentSession.disposeRace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
startAbandonedBranchSummaryInBackground,
type BranchSummaryAiService,
} from "./branchSummary";
import { createAgentSessionHarness } from "./agentSession.testHarness";
import { createAgentSessionHarness, createStreamLifecycleMocks } from "./agentSession.testHarness";
import type { StreamMessageOptions } from "./aiService";
import type { TurnCompletion } from "./streamManager";

Expand All @@ -39,6 +39,7 @@ describe("AgentSession disposal race conditions", () => {
const streamMessage = mock(() => Promise.resolve(Ok(undefined)));

const aiService: AIService = {
...createStreamLifecycleMocks(),
on(eventName: string | symbol, listener: (...args: unknown[]) => void) {
aiHandlers.set(String(eventName), listener);
return this;
Expand Down Expand Up @@ -136,6 +137,7 @@ describe("AgentSession disposal race conditions", () => {
test("bails out of a send parked on the branch-summary await when removal disposes the session", async () => {
const streamMessage = mock(() => Promise.resolve(Ok(undefined)));
const aiService: AIService = {
...createStreamLifecycleMocks(),
on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
return this;
},
Expand Down Expand Up @@ -254,6 +256,7 @@ describe("AgentSession disposal race conditions", () => {
const aiHandlers = new Map<string, (...args: unknown[]) => void>();

const aiService: AIService = {
...createStreamLifecycleMocks(),
on(eventName: string | symbol, listener: (...args: unknown[]) => void) {
aiHandlers.set(String(eventName), listener);
return this;
Expand Down Expand Up @@ -340,6 +343,7 @@ describe("AgentSession disposal race conditions", () => {
const aiHandlers = new Map<string, (...args: unknown[]) => void>();

const aiService: AIService = {
...createStreamLifecycleMocks(),
on(eventName: string | symbol, listener: (...args: unknown[]) => void) {
aiHandlers.set(String(eventName), listener);
return this;
Expand Down Expand Up @@ -434,6 +438,7 @@ describe("AgentSession disposal race conditions", () => {

test("does not reset auto-retry intent for synthetic or rejected sends", async () => {
const aiService: AIService = {
...createStreamLifecycleMocks(),
on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
return this;
},
Expand Down Expand Up @@ -578,6 +583,7 @@ describe("AgentSession disposal race conditions", () => {

test("preserves synthetic flag when flushing queued messages", () => {
const aiService: AIService = {
...createStreamLifecycleMocks(),
on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
return this;
},
Expand Down
3 changes: 2 additions & 1 deletion src/node/services/agentSession.editMessageId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createMuxMessage } from "@/common/types/message";
import { Ok } from "@/common/types/result";
import { AgentSession } from "./agentSession";
import { createTestHistoryService } from "./testHistoryService";
import { createStartedTurnHandle } from "./agentSession.testHarness";
import { createStartedTurnHandle, createStreamLifecycleMocks } from "./agentSession.testHarness";

type StreamMessageHandler = AIService["streamMessage"];

Expand Down Expand Up @@ -42,6 +42,7 @@ describe("AgentSession.sendMessage (editMessageId)", () => {

const streamMessage = mock(streamHandler);
const aiService = Object.assign(new EventEmitter(), {
...createStreamLifecycleMocks(),
isStreaming: mock((_workspaceId: string) => false),
stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))),
streamMessage: streamMessage as unknown as AIService["streamMessage"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { AIService, StreamMessageOptions } from "./aiService";
import type { BackgroundProcessManager } from "./backgroundProcessManager";
import type { InitStateManager } from "./initStateManager";
import { createTestHistoryService } from "./testHistoryService";
import { createStartedTurnHandle } from "./agentSession.testHarness";
import { createStartedTurnHandle, createStreamLifecycleMocks } from "./agentSession.testHarness";

/**
* Log purity: externally-edited files must produce a durable <system-file-update>
Expand Down Expand Up @@ -51,6 +51,7 @@ describe("AgentSession file-change notification (turn start)", () => {
return Promise.resolve(Ok(createStartedTurnHandle()));
});
const aiService: AIService = {
...createStreamLifecycleMocks(),
on: mock(() => aiService),
off: mock(() => aiService),
stopStream: mock(() => Promise.resolve(Ok(undefined))),
Expand Down
2 changes: 2 additions & 0 deletions src/node/services/agentSession.memoryContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Config } from "@/node/config";
import type { AIService } from "./aiService";
import type { MemorySessionContext } from "./memoryService";
import { AgentSession } from "./agentSession";
import { createStreamLifecycleMocks } from "./agentSession.testHarness";
import type { BackgroundProcessManager } from "./backgroundProcessManager";
import type { HistoryService } from "./historyService";
import type { InitStateManager } from "./initStateManager";
Expand All @@ -28,6 +29,7 @@ function createSession(args: {
}): AgentSession {
const aiEmitter = new EventEmitter();
const aiService: AIService = {
...createStreamLifecycleMocks(),
on(eventName: string | symbol, listener: (...args: unknown[]) => void) {
aiEmitter.on(String(eventName), listener);
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { Config } from "@/node/config";

import type { AIService } from "./aiService";
import { AgentSession } from "./agentSession";
import { createStreamLifecycleMocks } from "./agentSession.testHarness";
import type { BackgroundProcessManager } from "./backgroundProcessManager";
import type { HistoryService } from "./historyService";
import type { InitStateManager } from "./initStateManager";
Expand Down Expand Up @@ -104,6 +105,7 @@ function getAttachmentTypes(
function createSessionForHistory(historyService: HistoryService, sessionDir: string): AgentSession {
const aiEmitter = new EventEmitter();
const aiService: AIService = {
...createStreamLifecycleMocks(),
on(eventName: string | symbol, listener: (...args: unknown[]) => void) {
aiEmitter.on(String(eventName), listener);
return this;
Expand Down
3 changes: 2 additions & 1 deletion src/node/services/agentSession.postCompactionRefresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createTestHistoryService } from "./testHistoryService";
import type { CompactionCompletionMetadata } from "@/common/types/compaction";
import { createMuxMessage } from "@/common/types/message";
import type { StreamEndEvent } from "@/common/types/stream";
import { createAgentSessionHarness } from "./agentSession.testHarness";
import { createAgentSessionHarness, createStreamLifecycleMocks } from "./agentSession.testHarness";

// NOTE: These tests focus on the event wiring (tool-call-end -> callback).
// The actual post-compaction state computation is covered elsewhere.
Expand Down Expand Up @@ -173,6 +173,7 @@ describe("AgentSession post-compaction refresh trigger", () => {
const handlers = new Map<string, (...args: unknown[]) => void>();

const aiService: AIService = {
...createStreamLifecycleMocks(),
on(eventName: string | symbol, listener: (...args: unknown[]) => void) {
handlers.set(String(eventName), listener);
return this;
Expand Down
Loading
Loading