diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 78bca57a..cd541036 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -179,6 +179,8 @@ export interface ServerAdapterModule { models?: AdapterModel[]; listModels?: () => Promise; agentConfigurationDoc?: string; + /** Per D-04: If true, adapter manages its own context window (e.g. Claude, Codex). Session rotation is skipped but usage is still tracked. */ + nativeContextManagement?: boolean; /** * Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval). * adapterConfig is the agent's adapter config so the adapter can e.g. send a callback to a configured URL. diff --git a/src/main/connectors.ts b/src/main/connectors.ts index 27f782dc..96fb5030 100644 --- a/src/main/connectors.ts +++ b/src/main/connectors.ts @@ -57,6 +57,7 @@ const CONNECTORS: ConnectorDefinition[] = [ execute: codexExecute, testEnvironment: codexTestEnvironment, sessionCodec: codexSessionCodec, + nativeContextManagement: true, models: codexModels, }, listModels: async () => codexModels, @@ -81,6 +82,7 @@ const CONNECTORS: ConnectorDefinition[] = [ execute: claudeExecute, testEnvironment: claudeTestEnvironment, sessionCodec: claudeSessionCodec, + nativeContextManagement: true, models: claudeModels, }, listModels: async () => claudeModels, @@ -105,6 +107,7 @@ const CONNECTORS: ConnectorDefinition[] = [ execute: geminiExecute, testEnvironment: geminiTestEnvironment, sessionCodec: geminiSessionCodec, + nativeContextManagement: false, models: geminiModels, }, listModels: async () => geminiModels, diff --git a/src/main/database.ts b/src/main/database.ts index 221615ca..84a23e27 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -33,6 +33,7 @@ import type { SocialAccountRecord, StandupReport, TaskRecord, + TaskSessionRecord, WorkflowPipelineRecord, WorkspaceRecord, } from "@shared/types"; @@ -1429,6 +1430,99 @@ export class AppDatabase { }; } + // --------------------------------------------------------------------------- + // task_sessions CRUD — session management (Plan 06-01) + // --------------------------------------------------------------------------- + + getActiveTaskSession(taskId: string): TaskSessionRecord | null { + const task = this.db.prepare( + "SELECT active_session_id FROM tasks WHERE id = ?" + ).get(taskId) as { active_session_id: string | null } | undefined; + if (!task?.active_session_id) return null; + return this.db.prepare( + `SELECT id, company_id as companyId, task_id as taskId, agent_id as agentId, + adapter_type as adapterType, session_params_json as sessionParamsJson, + session_display_id as sessionDisplayId, last_run_id as lastRunId, + last_error as lastError, run_count as runCount, + total_input_tokens as totalInputTokens, total_output_tokens as totalOutputTokens, + rotated_at as rotatedAt, rotation_reason as rotationReason, + handoff_markdown as handoffMarkdown, created_at as createdAt, updated_at as updatedAt + FROM task_sessions WHERE id = ?` + ).get(task.active_session_id) as TaskSessionRecord | undefined ?? null; + } + + incrementSessionUsage(sessionId: string, inputTokens: number, outputTokens: number, runId: string): void { + const ts = nowIso(); + this.db.prepare( + `UPDATE task_sessions + SET run_count = run_count + 1, + total_input_tokens = total_input_tokens + ?, + total_output_tokens = total_output_tokens + ?, + last_run_id = ?, + updated_at = ? + WHERE id = ?` + ).run(inputTokens, outputTokens, runId, ts, sessionId); + } + + archiveSession(sessionId: string, reason: string, handoffMarkdown: string): void { + const ts = nowIso(); + this.db.prepare( + `UPDATE task_sessions SET rotated_at = ?, rotation_reason = ?, handoff_markdown = ?, updated_at = ? WHERE id = ?` + ).run(ts, reason, handoffMarkdown, ts, sessionId); + } + + createNewSession(input: { + taskId: string; + companyId: string; + agentId: string; + adapterType: string; + sessionParamsJson: string | null; + sessionDisplayId: string | null; + }): string { + const sessionId = randomUUID(); + const ts = nowIso(); + this.db.prepare( + `INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, + session_params_json, session_display_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + sessionId, input.companyId, input.taskId, input.agentId, input.adapterType, + input.sessionParamsJson, input.sessionDisplayId, ts, ts + ); + this.db.prepare("UPDATE tasks SET active_session_id = ?, updated_at = ? WHERE id = ?") + .run(sessionId, ts, input.taskId); + return sessionId; + } + + getRecentRunsForTask(taskId: string, limit: number): Array<{ + id: string; + status: string; + summary: string | null; + errorMessage: string | null; + finishedAt: string | null; + connectorId: string; + }> { + return this.readAll( + `SELECT id, status, summary, error_message as errorMessage, + finished_at as finishedAt, connector_id as connectorId + FROM runs WHERE task_id = ? ORDER BY finished_at DESC LIMIT ?`, + taskId, limit + ); + } + + getRecentActivityForTask(taskId: string, limit: number): Array<{ + action: string; + detail: string; + createdAt: string; + }> { + return this.readAll( + `SELECT action, detail, created_at as createdAt + FROM activity WHERE entity_type = 'task' AND entity_id = ? + ORDER BY created_at DESC LIMIT ?`, + taskId, limit + ); + } + addActivity(input: { companyId: string; actor: string; action: string; entityType: string; entityId: string; detail: string }) { this.db .prepare("insert into activity (id, company_id, actor, action, entity_type, entity_id, detail, created_at) values (?, ?, ?, ?, ?, ?, ?, ?)") diff --git a/src/shared/types.ts b/src/shared/types.ts index 1792f6ad..299fdaba 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -317,6 +317,26 @@ export interface BudgetStatusSummary { }>; } +export interface TaskSessionRecord { + id: string; + companyId: string; + taskId: string; + agentId: string; + adapterType: string; + sessionParamsJson: string | null; + sessionDisplayId: string | null; + lastRunId: string | null; + lastError: string | null; + runCount: number; + totalInputTokens: number; + totalOutputTokens: number; + rotatedAt: string | null; + rotationReason: string | null; + handoffMarkdown: string | null; + createdAt: string; + updatedAt: string; +} + export interface ActivityRecord { id: string; companyId: string; diff --git a/tests/unit/session-management.test.ts b/tests/unit/session-management.test.ts new file mode 100644 index 00000000..ab835479 --- /dev/null +++ b/tests/unit/session-management.test.ts @@ -0,0 +1,261 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + getSelectedStorageBackend: () => "basic_text", + encryptString: (value: string) => Buffer.from(value, "utf8"), + decryptString: (value: Buffer) => value.toString("utf8"), + }, +})); + +import { AppDatabase } from "../../src/main/database"; +import { getConnectorDefinition } from "../../src/main/connectors"; +import type { ConnectorId } from "@shared/types"; + +// --------------------------------------------------------------------------- +// Helpers — create a minimal company + agent + task scaffold +// --------------------------------------------------------------------------- +function scaffold(db: AppDatabase) { + const companyId = db.saveCompany({ + name: "Session Test Co", + description: "Testing session management", + status: "active", + }); + const workspaceId = db.saveWorkspace({ + companyId, + name: "Primary", + localPath: "/tmp/session-test", + repoUrl: "", + repoRef: "", + isPrimary: true, + }); + const agentId = db.saveAgent({ + companyId, + name: "Worker Agent", + role: "developer", + title: "Developer", + status: "active", + reportsTo: null, + connectorId: "claude_local", + workspaceId, + model: "claude-4-opus", + capabilities: "Writes code", + budgetMonthlyUsd: 100, + }); + const goalId = db.saveGoal({ + companyId, + title: "Ship feature", + description: "Build session management", + ownerAgentId: agentId, + status: "active", + }); + const projectId = db.saveProject({ + companyId, + goalId, + name: "Session Project", + description: "Session management work", + leadAgentId: agentId, + status: "in_progress", + targetDate: null, + }); + const taskId = db.saveTask({ + companyId, + projectId, + goalId, + title: "Implement session rotation", + description: "Build the session rotation logic for task execution.", + assigneeAgentId: agentId, + workspaceId, + priority: "high", + status: "in_progress", + }); + return { companyId, workspaceId, agentId, goalId, projectId, taskId }; +} + +// =========================================================================== +// task_sessions CRUD +// =========================================================================== +describe("task_sessions CRUD", () => { + let tempDir: string; + let db: AppDatabase; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "agentcompany-session-")); + db = new AppDatabase(tempDir); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("getActiveTaskSession returns null when active_session_id is null", () => { + const { taskId } = scaffold(db); + const session = db.getActiveTaskSession(taskId); + expect(session).toBeNull(); + }); + + it("getActiveTaskSession returns TaskSessionRecord when valid session exists", () => { + const { taskId, companyId, agentId } = scaffold(db); + const sessionId = db.createNewSession({ + taskId, + companyId, + agentId, + adapterType: "claude_local", + sessionParamsJson: JSON.stringify({ sessionId: "abc123" }), + sessionDisplayId: "claude-abc123", + }); + + const session = db.getActiveTaskSession(taskId); + expect(session).not.toBeNull(); + expect(session!.id).toBe(sessionId); + expect(session!.taskId).toBe(taskId); + expect(session!.companyId).toBe(companyId); + expect(session!.agentId).toBe(agentId); + expect(session!.adapterType).toBe("claude_local"); + expect(session!.sessionParamsJson).toBe(JSON.stringify({ sessionId: "abc123" })); + expect(session!.sessionDisplayId).toBe("claude-abc123"); + expect(session!.runCount).toBe(0); + expect(session!.totalInputTokens).toBe(0); + expect(session!.totalOutputTokens).toBe(0); + expect(session!.rotatedAt).toBeNull(); + expect(session!.rotationReason).toBeNull(); + expect(session!.handoffMarkdown).toBeNull(); + }); + + it("incrementSessionUsage atomically updates counters", () => { + const { taskId, companyId, agentId } = scaffold(db); + const sessionId = db.createNewSession({ + taskId, + companyId, + agentId, + adapterType: "claude_local", + sessionParamsJson: null, + sessionDisplayId: null, + }); + + db.incrementSessionUsage(sessionId, 1000, 500, "run-001"); + + const session = db.getActiveTaskSession(taskId); + expect(session!.runCount).toBe(1); + expect(session!.totalInputTokens).toBe(1000); + expect(session!.totalOutputTokens).toBe(500); + expect(session!.lastRunId).toBe("run-001"); + + // Second increment accumulates + db.incrementSessionUsage(sessionId, 2000, 800, "run-002"); + const session2 = db.getActiveTaskSession(taskId); + expect(session2!.runCount).toBe(2); + expect(session2!.totalInputTokens).toBe(3000); + expect(session2!.totalOutputTokens).toBe(1300); + expect(session2!.lastRunId).toBe("run-002"); + }); + + it("archiveSession sets rotated_at, rotation_reason, handoff_markdown", () => { + const { taskId, companyId, agentId } = scaffold(db); + const sessionId = db.createNewSession({ + taskId, + companyId, + agentId, + adapterType: "claude_local", + sessionParamsJson: null, + sessionDisplayId: null, + }); + + db.archiveSession(sessionId, "max_runs", "# Handoff\nSession reached 20 runs."); + + const session = db.getActiveTaskSession(taskId); + expect(session!.rotatedAt).toBeTruthy(); + expect(session!.rotationReason).toBe("max_runs"); + expect(session!.handoffMarkdown).toBe("# Handoff\nSession reached 20 runs."); + }); + + it("createNewSession creates session and updates tasks.active_session_id", () => { + const { taskId, companyId, agentId } = scaffold(db); + + const sessionId = db.createNewSession({ + taskId, + companyId, + agentId, + adapterType: "gemini_local", + sessionParamsJson: JSON.stringify({ key: "val" }), + sessionDisplayId: "gemini-session-1", + }); + + expect(typeof sessionId).toBe("string"); + expect(sessionId.length).toBeGreaterThan(0); + + // active_session_id should point to new session + const session = db.getActiveTaskSession(taskId); + expect(session).not.toBeNull(); + expect(session!.id).toBe(sessionId); + expect(session!.adapterType).toBe("gemini_local"); + }); + + it("getRecentRunsForTask returns last N runs ordered by finished_at DESC", () => { + const { taskId, companyId, agentId, workspaceId } = scaffold(db); + + // Create some runs + const run1 = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "claude_local" }); + db.finishRun({ + runId: run1.id, + status: "completed", + summary: "First run done", + errorMessage: null, + exitCode: 0, + signal: null, + model: "claude-4-opus", + sessionDisplayId: null, + costUsd: 0.01, + unattributedCost: false, + }); + + const run2 = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "claude_local" }); + db.finishRun({ + runId: run2.id, + status: "failed", + summary: "Second run failed", + errorMessage: "Timeout", + exitCode: 1, + signal: null, + model: "claude-4-opus", + sessionDisplayId: null, + costUsd: 0.02, + unattributedCost: false, + }); + + const runs = db.getRecentRunsForTask(taskId, 5); + expect(runs.length).toBe(2); + // Most recent first + expect(runs[0].id).toBe(run2.id); + expect(runs[0].status).toBe("failed"); + expect(runs[0].errorMessage).toBe("Timeout"); + expect(runs[1].id).toBe(run1.id); + expect(runs[1].status).toBe("completed"); + }); +}); + +// =========================================================================== +// adapter exemption +// =========================================================================== +describe("adapter exemption", () => { + it("claude_local connector has nativeContextManagement: true", () => { + const def = getConnectorDefinition("claude_local" as ConnectorId); + expect(def.module.nativeContextManagement).toBe(true); + }); + + it("codex_local connector has nativeContextManagement: true", () => { + const def = getConnectorDefinition("codex_local" as ConnectorId); + expect(def.module.nativeContextManagement).toBe(true); + }); + + it("gemini_local connector has nativeContextManagement: false", () => { + const def = getConnectorDefinition("gemini_local" as ConnectorId); + expect(def.module.nativeContextManagement).toBe(false); + }); +});