diff --git a/src/__tests__/integration/control-plane/session-lifecycle.test.ts b/src/__tests__/integration/control-plane/session-lifecycle.test.ts index 7d2a6b2b..521e4ffc 100644 --- a/src/__tests__/integration/control-plane/session-lifecycle.test.ts +++ b/src/__tests__/integration/control-plane/session-lifecycle.test.ts @@ -243,6 +243,31 @@ describe('control-plane session lifecycle API', () => { }); }); + it('returns selected-session runtime context for commands and status surfaces', async () => { + const { caller } = createControlPlaneCaller(); + const session = await caller.sessionCreate({ name: 'Runtime context session', model: 'gpt-5.4' }); + await caller.sessionSettingsUpdate({ + id: session.id, + reasoningEffort: 'medium', + driftEnabled: true, + }); + + await expect(caller.sessionRuntimeContext({ + sessionId: session.id, + })).resolves.toMatchObject({ + workspaceId: DEFAULT_WORKSPACE_ID, + sessionId: session.id, + sessionName: 'Runtime context session', + model: 'gpt-5.4', + reasoningEffort: 'medium', + effectiveReasoningEffort: 'medium', + reasoningSupported: true, + contextWindow: 400000, + driftEnabled: true, + running: false, + }); + }); + it('returns visible errors for unknown slash commands', async () => { const { caller } = createControlPlaneCaller(); const session = await caller.sessionCreate({ name: 'Unknown slash command session' }); diff --git a/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts b/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts index 676ff121..88e9b93e 100644 --- a/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts +++ b/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts @@ -4,6 +4,7 @@ import type { ControlPlanePendingApproval, ControlPlaneSessionDetail, ControlPlaneSessionEventEnvelope, + ControlPlaneSessionRuntimeContext, ControlPlaneSessionSendPromptAsyncResult, ControlPlaneSessionView, ControlPlaneSessionsEventEnvelope, @@ -22,12 +23,20 @@ describe('ControlPlaneSessionStore', () => { expect(fixture.calls.slashCommandCatalogQuery).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }); expect(fixture.calls.sessionsQuery).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }); expect(fixture.calls.sessionQuery).toHaveBeenCalledWith({ id: 'session-1', workspaceId: 'workspace-1' }); + expect(fixture.calls.sessionRuntimeContextQuery).toHaveBeenCalledWith({ + sessionId: 'session-1', + workspaceId: 'workspace-1', + }); expect(store.getSnapshot()).toMatchObject({ workspaceId: 'workspace-1', activeSessionId: 'session-1', loading: false, running: false, pendingApproval: null, + runtimeContext: expect.objectContaining({ + model: 'gpt-5.4', + effectiveReasoningEffort: 'medium', + }), }); expect(store.getSnapshot().activeSession?.messages).toEqual([ { id: 'message-1', role: 'assistant', text: 'Ready.' }, @@ -35,6 +44,29 @@ describe('ControlPlaneSessionStore', () => { store.dispose(); }); + it('refreshes runtime context when selecting a different session', async () => { + const fixture = createClientFixture(); + const store = new ControlPlaneSessionStore({ client: fixture.client }); + await store.start(); + fixture.calls.sessionRuntimeContextQuery.mockResolvedValueOnce(createRuntimeContext({ + sessionId: 'session-2', + sessionName: 'Session 2', + model: 'gpt-5.4-mini', + })); + + await store.selectSession('session-2'); + + expect(fixture.calls.sessionRuntimeContextQuery).toHaveBeenLastCalledWith({ + workspaceId: 'workspace-1', + sessionId: 'session-2', + }); + expect(store.getSnapshot().runtimeContext).toMatchObject({ + sessionId: 'session-2', + model: 'gpt-5.4-mini', + }); + store.dispose(); + }); + it('submits prompts through sessionSendPromptAsync without a direct runtime fallback', async () => { const fixture = createClientFixture(); const store = new ControlPlaneSessionStore({ @@ -485,6 +517,7 @@ function createClientFixture() { sessionQuery: vi.fn(async () => sessionDetail), sessionRunningQuery: vi.fn(async () => ({ running: false })), sessionRunStateQuery: vi.fn(async () => ({ running: false, pendingApproval })), + sessionRuntimeContextQuery: vi.fn(async () => createRuntimeContext()), sessionPendingApprovalQuery: vi.fn(async () => pendingApproval), sessionSendPromptMutate: vi.fn(async () => ({ session: { @@ -555,6 +588,7 @@ function createClientFixture() { }, sessionRunning: { query: calls.sessionRunningQuery }, sessionRunState: { query: calls.sessionRunStateQuery }, + sessionRuntimeContext: { query: calls.sessionRuntimeContextQuery }, sessionPendingApproval: { query: calls.sessionPendingApprovalQuery }, sessionSendPrompt: { mutate: calls.sessionSendPromptMutate }, sessionSendPromptAsync: { mutate: calls.sessionSendPromptAsyncMutate }, @@ -602,6 +636,31 @@ function createSessionDetail(): NonNullable { }; } +function createRuntimeContext( + overrides: Partial = {}, +): ControlPlaneSessionRuntimeContext { + return { + workspaceId: 'workspace-1', + sessionId: 'session-1', + sessionName: 'Session 1', + model: 'gpt-5.4', + reasoningEffort: 'medium', + effectiveReasoningEffort: 'medium', + reasoningSupported: true, + credentialSource: { + type: 'oauth', + provider: 'openai', + accountId: 'acct-test', + expiresAt: Date.now() + 60_000, + }, + contextWindow: 400000, + estimatedInputTokens: undefined, + driftEnabled: false, + running: false, + ...overrides, + }; +} + function createAcceptedResult(): ControlPlaneSessionSendPromptAsyncResult { return { accepted: true, diff --git a/src/__tests__/unit/cli-v2/runtime-status.test.ts b/src/__tests__/unit/cli-v2/runtime-status.test.ts new file mode 100644 index 00000000..ac5fab14 --- /dev/null +++ b/src/__tests__/unit/cli-v2/runtime-status.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { RuntimeStatusService } from '../../../cli-v2/services/status/index.js'; +import type { ControlPlaneSessionStoreSnapshot } from '../../../cli-v2/state/control-plane-session-store.js'; + +describe('RuntimeStatusService', () => { + it('formats runtime context for the cli-v2 status bar', () => { + expect(RuntimeStatusService.build(createSnapshot())).toBe( + 'model=gpt-5.4 • reasoning=medium • auth=openai-oauth:acct-tes • context window ~400,000 tokens • drift=off • session=session-1 (Session 1)', + ); + }); + + it('includes estimated input usage and running status when available', () => { + expect(RuntimeStatusService.build(createSnapshot({ + running: true, + runtimeContext: { + ...createSnapshot().runtimeContext!, + estimatedInputTokens: 20000, + driftEnabled: true, + driftLevel: 'low', + running: true, + }, + }))).toBe( + 'model=gpt-5.4 • reasoning=medium • auth=openai-oauth:acct-tes • estimated input 20,000 / 400,000 tokens (5%) • drift=low • session=session-1 (Session 1) • status=running', + ); + }); +}); + +function createSnapshot(overrides: Partial = {}): ControlPlaneSessionStoreSnapshot { + return { + workspaceId: 'workspace-1', + sessions: [], + activeSessionId: 'session-1', + activeSession: null, + runtimeContext: { + workspaceId: 'workspace-1', + sessionId: 'session-1', + sessionName: 'Session 1', + model: 'gpt-5.4', + reasoningEffort: 'medium', + effectiveReasoningEffort: 'medium', + reasoningSupported: true, + credentialSource: { + type: 'oauth', + provider: 'openai', + accountId: 'acct-test', + expiresAt: Date.now() + 60_000, + }, + contextWindow: 400000, + driftEnabled: false, + running: false, + }, + pendingApproval: null, + loading: false, + submitting: false, + approvalResolving: false, + running: false, + cancelling: false, + streamConnected: false, + commandResults: [], + ...overrides, + }; +} diff --git a/src/cli-v2/App.tsx b/src/cli-v2/App.tsx index d8b61aff..5e37a841 100644 --- a/src/cli-v2/App.tsx +++ b/src/cli-v2/App.tsx @@ -5,6 +5,7 @@ import { CommandResultPanel } from './components/CommandResultPanel.js'; import { ConversationPanel } from './components/ConversationPanel.js'; import { PromptInput } from './components/PromptInput.js'; import { RunControls } from './components/RunControls.js'; +import { RuntimeStatusBar } from './components/RuntimeStatusBar.js'; import { SlashCommandHintPanel } from './components/SlashCommandHintPanel.js'; import { useControlPlaneSessionStore } from './hooks/useControlPlaneSessionStore.js'; import { usePromptDraft } from './hooks/usePromptDraft.js'; @@ -102,6 +103,7 @@ export function App({ onSubmit={submitPrompt} onComplete={(value) => store.completeSlashCommandDraft(value)} /> + ); } diff --git a/src/cli-v2/components/RuntimeStatusBar.tsx b/src/cli-v2/components/RuntimeStatusBar.tsx new file mode 100644 index 00000000..85b9467b --- /dev/null +++ b/src/cli-v2/components/RuntimeStatusBar.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import type { ControlPlaneSessionStoreSnapshot } from '../state/control-plane-session-store.js'; +import { RuntimeStatusService } from '../services/status/index.js'; + +export function RuntimeStatusBar({ + snapshot, +}: { + snapshot: ControlPlaneSessionStoreSnapshot; +}) { + return ( + + {RuntimeStatusService.build(snapshot)} + + ); +} diff --git a/src/cli-v2/services/README.md b/src/cli-v2/services/README.md index 54666922..084a5a51 100644 --- a/src/cli-v2/services/README.md +++ b/src/cli-v2/services/README.md @@ -32,3 +32,4 @@ Current domains: to Ink rendering and cli-v2 store coordination. - `slash-commands/`: local hint filtering and tab completion over control-plane-provided slash command metadata. +- `status/`: terminal status-bar formatting over control-plane runtime context. diff --git a/src/cli-v2/services/sessions/control-plane-session-api-service.ts b/src/cli-v2/services/sessions/control-plane-session-api-service.ts index 22ae72f8..ff1369bd 100644 --- a/src/cli-v2/services/sessions/control-plane-session-api-service.ts +++ b/src/cli-v2/services/sessions/control-plane-session-api-service.ts @@ -73,6 +73,10 @@ export class ControlPlaneSessionApiService { return this.client.controlPlane.sessionRunState.query({ id: sessionId, workspaceId }); } + async getRuntimeContext(workspaceId: string, sessionId: string) { + return this.client.controlPlane.sessionRuntimeContext.query({ sessionId, workspaceId }); + } + async getPendingApproval(workspaceId: string, sessionId: string) { return this.client.controlPlane.sessionPendingApproval.query({ id: sessionId, workspaceId }); } diff --git a/src/cli-v2/services/status/index.ts b/src/cli-v2/services/status/index.ts new file mode 100644 index 00000000..442c140b --- /dev/null +++ b/src/cli-v2/services/status/index.ts @@ -0,0 +1 @@ +export { RuntimeStatusService } from './runtime-status-service.js'; diff --git a/src/cli-v2/services/status/runtime-status-service.ts b/src/cli-v2/services/status/runtime-status-service.ts new file mode 100644 index 00000000..5e61ee87 --- /dev/null +++ b/src/cli-v2/services/status/runtime-status-service.ts @@ -0,0 +1,58 @@ +import type { ControlPlaneSessionRuntimeContext } from '@/client-shared/api/types.js'; +import type { ControlPlaneSessionStoreSnapshot } from '../../state/control-plane-session-store.js'; + +export class RuntimeStatusService { + static build(snapshot: ControlPlaneSessionStoreSnapshot): string { + const context = snapshot.runtimeContext; + if (!context) { + return snapshot.workspaceId ? 'runtime context loading...' : 'workspace loading...'; + } + + return [ + `model=${context.model}`, + `reasoning=${RuntimeStatusService.formatReasoning(context)}`, + RuntimeStatusService.formatAuth(context), + RuntimeStatusService.formatContextWindow(context), + `drift=${context.driftEnabled ? context.driftLevel ?? 'unknown' : 'off'}`, + `session=${context.sessionId} (${context.sessionName})`, + snapshot.running ? 'status=running' : undefined, + ].filter((item): item is string => Boolean(item)).join(' • '); + } + + private static formatReasoning(context: ControlPlaneSessionRuntimeContext): string { + if (!context.reasoningSupported) { + return 'unsupported'; + } + + return context.effectiveReasoningEffort ?? 'default'; + } + + private static formatAuth(context: ControlPlaneSessionRuntimeContext): string { + const source = context.credentialSource; + switch (source.type) { + case 'explicit-api-key': + return 'auth=explicit-key'; + case 'env-api-key': + return `auth=${source.provider}-key`; + case 'oauth': + return source.accountId ? `auth=${source.provider}-oauth:${source.accountId.slice(0, 8)}` : `auth=${source.provider}-oauth`; + case 'missing': + return `auth=missing-${source.provider}`; + } + } + + private static formatContextWindow(context: ControlPlaneSessionRuntimeContext): string { + if (!context.contextWindow) { + return context.estimatedInputTokens + ? `estimated input tokens ${context.estimatedInputTokens.toLocaleString()}` + : 'context window unknown'; + } + + if (!context.estimatedInputTokens) { + return `context window ~${context.contextWindow.toLocaleString()} tokens`; + } + + const percent = Math.round(Math.min(1, context.estimatedInputTokens / context.contextWindow) * 100); + return `estimated input ${context.estimatedInputTokens.toLocaleString()} / ${context.contextWindow.toLocaleString()} tokens (${percent}%)`; + } +} diff --git a/src/cli-v2/state/control-plane-session-store.ts b/src/cli-v2/state/control-plane-session-store.ts index 5fe4e8cd..e43b9f50 100644 --- a/src/cli-v2/state/control-plane-session-store.ts +++ b/src/cli-v2/state/control-plane-session-store.ts @@ -24,6 +24,7 @@ import type { ControlPlanePendingApproval, ControlPlaneSessionDetail, ControlPlaneSessionEventEnvelope, + ControlPlaneSessionRuntimeContext, ControlPlaneSessionView, ControlPlaneSlashCommandCatalog, ControlPlaneSlashCommandHint, @@ -53,6 +54,7 @@ export type ControlPlaneSessionStoreSnapshot = { sessions: ControlPlaneSessionView[]; activeSessionId?: string; activeSession: ControlPlaneSessionDetail; + runtimeContext?: ControlPlaneSessionRuntimeContext; pendingApproval: ControlPlanePendingApproval; loading: boolean; submitting: boolean; @@ -70,6 +72,7 @@ export type ControlPlaneSessionStoreSnapshot = { const INITIAL_SNAPSHOT: ControlPlaneSessionStoreSnapshot = { sessions: [], activeSession: null, + runtimeContext: undefined, pendingApproval: null, loading: false, submitting: false, @@ -182,6 +185,7 @@ export class ControlPlaneSessionStore { this.setSnapshot({ activeSessionId: sessionId, activeSession: null, + runtimeContext: undefined, pendingApproval: null, liveStatus: undefined, latestUpdate: undefined, @@ -192,9 +196,11 @@ export class ControlPlaneSessionStore { try { const session = await this.api.getSession(workspaceId, sessionId); + const runtimeContext = await this.api.getRuntimeContext(workspaceId, sessionId); const running = await this.api.getRunning(workspaceId, sessionId); this.setSnapshot({ activeSession: session, + runtimeContext, running: running.running, loading: false, }); @@ -379,10 +385,12 @@ export class ControlPlaneSessionStore { try { const next = await this.api.getSession(workspaceId, sessionId); + const runtimeContext = await this.api.getRuntimeContext(workspaceId, sessionId); this.setSnapshot((current) => ({ activeSession: options.silent ? ClientSharedSessionMessageService.mergeTransientMessages(current.activeSession, next) : next, + runtimeContext, loading: false, })); } catch (error) { @@ -625,6 +633,9 @@ export class ControlPlaneSessionStore { this.setSnapshot({ pendingApproval: runState.pendingApproval, running: runState.running, + runtimeContext: this.snapshotValue.runtimeContext + ? { ...this.snapshotValue.runtimeContext, running: runState.running } + : this.snapshotValue.runtimeContext, cancelling: runState.running ? this.snapshotValue.cancelling : false, latestUpdate: runState.pendingApproval ? { diff --git a/src/client-shared/api/types.ts b/src/client-shared/api/types.ts index 69096f1e..19bb954a 100644 --- a/src/client-shared/api/types.ts +++ b/src/client-shared/api/types.ts @@ -17,6 +17,7 @@ export type ControlPlaneSessionEventEnvelope = AsyncIterableValue; export type ControlPlaneHeartbeatEventEnvelope = AsyncIterableValue; export type ControlPlaneSessionMessage = NonNullable['messages'][number]; +export type ControlPlaneSessionRuntimeContext = RouterOutputs['controlPlane']['sessionRuntimeContext']; export type ControlPlanePendingApproval = RouterOutputs['controlPlane']['sessionPendingApproval']; export type ControlPlaneApprovalDecision = RouterInputs['controlPlane']['sessionResolveApproval']['decision']; export type ControlPlaneSessionSendPromptResult = RouterOutputs['controlPlane']['sessionSendPrompt']; diff --git a/src/server/control-plane-types.ts b/src/server/control-plane-types.ts index aac70097..1e6e12b2 100644 --- a/src/server/control-plane-types.ts +++ b/src/server/control-plane-types.ts @@ -149,6 +149,23 @@ export type ChatSessionDetail = ChatSessionView & { lastContinuePrompt?: string; }; +export type ControlPlaneSessionRuntimeContext = { + workspaceId: string; + sessionId: string; + sessionName: string; + model: string; + reasoningEffort?: ReasoningEffort; + effectiveReasoningEffort?: ReasoningEffort; + reasoningSupported: boolean; + credentialSource: ProviderCredentialSource; + contextWindow?: number; + estimatedInputTokens?: number; + driftEnabled: boolean; + driftLevel?: ChatSessionView['driftLevel']; + compactionStatus?: NonNullable['compaction']>['status']; + running: boolean; +}; + export type ControlPlaneAcceptedSessionRun = { accepted: true; workspaceId: string; diff --git a/src/server/controllers/trpc/control-plane/chat-session-presenter.ts b/src/server/controllers/trpc/control-plane/chat-session-presenter.ts index 633ebd07..de085e1d 100644 --- a/src/server/controllers/trpc/control-plane/chat-session-presenter.ts +++ b/src/server/controllers/trpc/control-plane/chat-session-presenter.ts @@ -1,4 +1,3 @@ -import { existsSync, readFileSync } from 'node:fs'; import type { ChatSession } from '@/core/chat/types.js'; import type { ReasoningEffort } from '@/core/llm/types.js'; import type { @@ -7,6 +6,7 @@ import type { ChatSessionView, ChatTurnView, } from '@/server/control-plane-types.js'; +import { ControlPlaneSessionDriftService } from '@/server/services/control-plane/session-drift-service.js'; import { omitUndefined, readBoolean, @@ -113,7 +113,7 @@ export class ControlPlaneChatSessionPresenter { model: readString(candidate.model), reasoningEffort: ControlPlaneChatSessionPresenter.readReasoningEffort(candidate.reasoningEffort), driftEnabled: typeof candidate.driftEnabled === 'boolean' ? candidate.driftEnabled : undefined, - driftLevel: ControlPlaneChatSessionPresenter.readLatestDriftLevel(turns), + driftLevel: ControlPlaneSessionDriftService.readLatestDriftLevel(turns), messageCount: messages.length, turnCount: turns.length, lastPrompt: readString(lastTurn?.prompt), @@ -195,48 +195,6 @@ export class ControlPlaneChatSessionPresenter { private static readReasoningEffort(value: unknown): ReasoningEffort | undefined { return value === 'low' || value === 'medium' || value === 'high' || value === 'ultrahigh' ? value : undefined; } - - private static readLatestDriftLevel(turns: unknown[]): ChatSessionView['driftLevel'] { - for (let index = turns.length - 1; index >= 0; index--) { - const turn = readObject(turns[index]); - const traceFile = readString(turn?.traceFile); - const driftLevel = traceFile ? ControlPlaneChatSessionPresenter.readLatestDriftLevelFromTrace(traceFile) : undefined; - if (driftLevel) { - return driftLevel; - } - } - - return undefined; - } - - private static readLatestDriftLevelFromTrace(traceFile: string): ChatSessionView['driftLevel'] { - if (!traceFile || !existsSync(traceFile)) { - return undefined; - } - - try { - const parsed = JSON.parse(readFileSync(traceFile, 'utf8')) as unknown; - if (!Array.isArray(parsed)) { - return undefined; - } - - for (let index = parsed.length - 1; index >= 0; index--) { - const event = readObject(parsed[index]); - if (event?.type !== 'cyberloop.annotation') { - continue; - } - - const driftLevel = readString(event.driftLevel); - if (driftLevel === 'unknown' || driftLevel === 'low' || driftLevel === 'medium' || driftLevel === 'high') { - return driftLevel; - } - } - } catch { - return undefined; - } - - return undefined; - } } function omitEmpty>(value: T): T | undefined { diff --git a/src/server/controllers/trpc/control-plane/slash-commands-controller.ts b/src/server/controllers/trpc/control-plane/slash-commands-controller.ts index 5a4885f0..18229be7 100644 --- a/src/server/controllers/trpc/control-plane/slash-commands-controller.ts +++ b/src/server/controllers/trpc/control-plane/slash-commands-controller.ts @@ -1,19 +1,9 @@ -import { ProviderCredentialRepository } from '@/core/auth/index.js'; -import { createConversationEngine } from '@/core/chat/engine/conversation-engine.js'; -import { ChatSessionRecords } from '@/core/chat/engine/sessions/records/index.js'; import type { ConversationEngineConfig } from '@/core/chat/engine/types.js'; -import type { ChatSession } from '@/core/chat/types.js'; import { SlashCommandRegistry } from '@/core/commands/slash/registry.js'; import { createCoreSlashCommandModules } from '@/core/commands/slash/modules/core-command-modules.js'; -import type { SlashCommandExecutionContext } from '@/core/commands/slash/modules/context.js'; import type { SlashCommandResult } from '@/core/commands/slash/result-types.js'; -import { DEFAULT_OPENAI_MODEL } from '@/core/config.js'; -import { FileHeartbeatTaskService } from '@/core/heartbeat/index.js'; -import { RuntimeCredentialService } from '@/core/runtime/credentials/index.js'; -import type { LlmProvider } from '@/core/llm/types.js'; -import { controlPlaneChatSessionsController } from './chat-sessions-controller.js'; import type { ChatSessionLeaseOwner } from '@/core/chat/engine/sessions/leases/index.js'; -import type { SlashCommandHint } from '@/core/commands/slash/types.js'; +import { controlPlaneSlashCommandExecutionContextService } from '@/server/services/control-plane/slash-command-execution-context-service.js'; type SlashCommandControllerArgs = Omit & { model?: string; @@ -21,6 +11,7 @@ type SlashCommandControllerArgs = Omit & { workspaceId: string; sessionId: string; leaseOwner: ChatSessionLeaseOwner; + compactActive: () => Promise | string; }; const registry = new SlashCommandRegistry(createCoreSlashCommandModules()); @@ -42,149 +33,16 @@ export class ControlPlaneSlashCommandsController { } async execute(args: SlashCommandControllerArgs, command: string): Promise { - const result = await registry.run(this.createExecutionContext(args), command.trim()); + const result = await registry.run( + controlPlaneSlashCommandExecutionContextService.create(args, registry.hints()), + command.trim(), + ); return result ?? { handled: true, kind: 'message', message: `Unknown command: ${command.trim()}. Use the slash command hints to inspect available commands.`, }; } - - private createExecutionContext(args: SlashCommandControllerArgs): SlashCommandExecutionContext { - const engine = createConversationEngine({ - ...args, - model: args.model ?? DEFAULT_OPENAI_MODEL, - }); - const sessions = engine.sessions; - const heartbeatTasks = new FileHeartbeatTaskService({ stateRoot: args.stateRoot }); - - return { - model: { - active: () => sessions.require(args.sessionId).model ?? args.model ?? DEFAULT_OPENAI_MODEL, - setActive: (model) => { - sessions.updateSettings(args.sessionId, { model }); - }, - activeReasoningEffort: () => sessions.require(args.sessionId).reasoningEffort, - setReasoningEffort: (reasoningEffort) => { - sessions.updateSettings(args.sessionId, { reasoningEffort }); - }, - credentialSource: () => RuntimeCredentialService.resolveCredentialSourceForModel( - sessions.require(args.sessionId).model ?? args.model ?? DEFAULT_OPENAI_MODEL, - args, - ), - }, - auth: { - status: () => this.formatAuthStatus(args.credentialStorePath), - login: async (provider) => { - throw new Error(`OAuth login for ${provider} is only available through the terminal auth command.`); - }, - logout: (provider) => this.logoutProvider(provider, args.credentialStorePath), - }, - compaction: { - compactActive: async () => { - await controlPlaneChatSessionsController.compactSession({ - ...args, - force: true, - }); - return 'Compacted earlier session history for the next run.'; - }, - }, - drift: { - status: () => { - const session = sessions.require(args.sessionId); - return { enabled: session.driftEnabled ?? false }; - }, - setEnabled: (enabled) => { - sessions.setDriftEnabled(args.sessionId, enabled); - }, - }, - session: { - all: () => sessions.listExisting(), - recent: () => this.recentSessions(sessions.listExisting()), - recentListMessage: () => this.recentSessionMessages(sessions.listExisting()), - create: (name) => sessions.create({ - name, - model: sessions.require(args.sessionId).model ?? args.model, - workspaceId: args.workspaceId, - }), - switch: (id) => { - sessions.require(id); - }, - rename: (name) => { - sessions.rename(args.sessionId, name); - }, - remove: (id) => { - sessions.delete(id); - }, - clear: () => { - const model = sessions.require(args.sessionId).model ?? args.model ?? DEFAULT_OPENAI_MODEL; - sessions.resetConversation(args.sessionId, { - apiKeyPresent: RuntimeCredentialService.hasCredentialForModel(model, args), - }); - }, - summarize: ChatSessionRecords.summarize, - }, - heartbeat: { - listTasks: async () => await heartbeatTasks.listTasks(), - listRunRecords: async (options) => await heartbeatTasks.listRunRecords(options), - loadRunRecord: async (id) => await heartbeatTasks.loadRunRecord(id), - }, - help: { - message: () => this.formatHelpMessage(registry.hints()), - }, - }; - } - - private recentSessions(sessions: ChatSession[]): ChatSession[] { - return [...sessions].sort((left, right) => (right.updatedAt ?? '').localeCompare(left.updatedAt ?? '')).slice(0, 10); - } - - private recentSessionMessages(sessions: ChatSession[]): string[] { - return this.recentSessions(sessions).map((session, index) => ( - `${index + 1}. ${session.id} (${session.name}) - ${ChatSessionRecords.summarize(session)}` - )); - } - - private formatAuthStatus(storePath = ProviderCredentialRepository.resolveStorePath()): string { - const summaries = new ProviderCredentialRepository({ storePath }).listSummaries(); - const lines = [`Auth store: ${storePath}`]; - if (summaries.length === 0) { - return [...lines, 'Stored credentials: none'].join('\n'); - } - - return [ - ...lines, - 'Stored credentials:', - ...summaries.map((summary) => { - const details = [ - `type=${summary.type}`, - summary.label ? `label=${summary.label}` : undefined, - summary.accountId ? `account=${summary.accountId}` : undefined, - summary.expiresAt ? `expires=${new Date(summary.expiresAt).toISOString()}` : undefined, - summary.expired === true ? 'expired=true' : undefined, - `updated=${summary.updatedAt}`, - ].filter(Boolean); - return `- ${summary.provider}: ${details.join(' ')}`; - }), - ].join('\n'); - } - - private logoutProvider(provider: LlmProvider, storePath = ProviderCredentialRepository.resolveStorePath()): string { - const removed = new ProviderCredentialRepository({ storePath }).remove(provider); - return removed ? `Removed stored ${provider} credential.` : `No stored ${provider} credential found.`; - } - - private formatHelpMessage(hints: SlashCommandHint[]): string { - return [ - 'Slash commands', - '', - ...hints.flatMap((hint) => [hint.command, capitalizeFirst(hint.description), '']), - ].join('\n').trimEnd(); - } } export const controlPlaneSlashCommandsController = new ControlPlaneSlashCommandsController(); - -function capitalizeFirst(value: string): string { - return value.length > 0 ? `${value[0]?.toUpperCase()}${value.slice(1)}.` : value; -} diff --git a/src/server/routes/trpc/control-plane.ts b/src/server/routes/trpc/control-plane.ts index b48c19ac..4c5051be 100644 --- a/src/server/routes/trpc/control-plane.ts +++ b/src/server/routes/trpc/control-plane.ts @@ -16,6 +16,7 @@ import { ControlPlaneLayoutSnapshotsController } from '@/server/controllers/trpc import { ControlPlaneWorkspaceFilesController } from '@/server/controllers/trpc/control-plane/workspace-files.js'; import { ControlPlaneWorkspaceDiffController } from '@/server/controllers/trpc/control-plane/workspace-diff.js'; import { controlPlaneSlashCommandsController } from '@/server/controllers/trpc/control-plane/slash-commands-controller.js'; +import { controlPlaneSessionRuntimeContextService } from '@/server/services/control-plane/session-runtime-context-service.js'; import { RuntimeWorkspaceService } from '@/core/runtime/workspaces/index.js'; import { FileDaemonRegistryRepository, RuntimeDaemonRegistryService } from '@/core/runtime/daemon/index.js'; import { controlPlaneWorkspaceProcedure, type ControlPlaneWorkspaceContext } from './control-plane-workspace.js'; @@ -40,6 +41,7 @@ import { sessionInputSchema, sessionMessageInputSchema, sessionRenameInputSchema, + sessionRuntimeContextInputSchema, slashCommandCatalogInputSchema, slashCommandExecuteInputSchema, sessionsEventsInputSchema, @@ -176,6 +178,19 @@ export const controlPlaneRouter = router({ sessionId: input.id, }); }), + sessionRuntimeContext: controlPlaneWorkspaceProcedure.input(sessionRuntimeContextInputSchema).query(({ ctx, input }) => { + const { workspace, sessionEngineArgs } = ctx.requestWorkspace; + return controlPlaneSessionRuntimeContextService.read({ + ...sessionEngineArgs, + sessionId: input.sessionId, + preferApiKey: ctx.preferApiKey, + }, { + running: controlPlaneChatSessionsController.isRunning({ + workspaceId: workspace.id, + sessionId: input.sessionId, + }), + }); + }), sessionPendingApproval: controlPlaneWorkspaceProcedure.input(sessionInputSchema).query(({ ctx, input }) => { const { workspace } = ctx.requestWorkspace; return controlPlaneChatSessionsController.getPendingApproval({ @@ -225,6 +240,16 @@ export const controlPlaneRouter = router({ sessionId: input.sessionId, preferApiKey: ctx.preferApiKey, leaseOwner: resolveControlPlaneLeaseOwner(ctx), + compactActive: async () => { + await controlPlaneChatSessionsController.compactSession({ + ...ctx.requestWorkspace.sessionEngineArgs, + sessionId: input.sessionId, + force: true, + preferApiKey: ctx.preferApiKey, + leaseOwner: resolveControlPlaneLeaseOwner(ctx), + }); + return 'Compacted earlier session history for the next run.'; + }, }, input.command); }), sessionContinue: controlPlaneWorkspaceProcedure.input(sessionInputSchema).mutation(async ({ ctx, input }) => { diff --git a/src/server/routes/trpc/schema.ts b/src/server/routes/trpc/schema.ts index e45b8e31..973653cf 100644 --- a/src/server/routes/trpc/schema.ts +++ b/src/server/routes/trpc/schema.ts @@ -51,6 +51,11 @@ export const sessionMessageInputSchema = z.object({ memoryMaintenanceMode: z.enum(['background', 'inline', 'none']).optional(), }); +export const sessionRuntimeContextInputSchema = z.object({ + workspaceId: z.string().min(1).optional(), + sessionId: z.string().min(1), +}); + export const slashCommandCatalogInputSchema = z.object({ workspaceId: z.string().min(1).optional(), }).optional(); diff --git a/src/server/services/control-plane/session-drift-service.ts b/src/server/services/control-plane/session-drift-service.ts new file mode 100644 index 00000000..77b60a22 --- /dev/null +++ b/src/server/services/control-plane/session-drift-service.ts @@ -0,0 +1,50 @@ +import { existsSync, readFileSync } from 'node:fs'; +import type { ChatSessionView } from '@/server/control-plane-types.js'; +import { + readObject, + readString, +} from '@/server/helpers/control-plane-read-values.js'; + +export class ControlPlaneSessionDriftService { + static readLatestDriftLevel(turns: unknown[]): ChatSessionView['driftLevel'] { + for (let index = turns.length - 1; index >= 0; index--) { + const turn = readObject(turns[index]); + const traceFile = readString(turn?.traceFile); + const driftLevel = traceFile ? ControlPlaneSessionDriftService.readLatestDriftLevelFromTrace(traceFile) : undefined; + if (driftLevel) { + return driftLevel; + } + } + + return undefined; + } + + private static readLatestDriftLevelFromTrace(traceFile: string): ChatSessionView['driftLevel'] { + if (!traceFile || !existsSync(traceFile)) { + return undefined; + } + + try { + const parsed = JSON.parse(readFileSync(traceFile, 'utf8')) as unknown; + if (!Array.isArray(parsed)) { + return undefined; + } + + for (let index = parsed.length - 1; index >= 0; index--) { + const event = readObject(parsed[index]); + if (event?.type !== 'cyberloop.annotation') { + continue; + } + + const driftLevel = readString(event.driftLevel); + if (driftLevel === 'unknown' || driftLevel === 'low' || driftLevel === 'medium' || driftLevel === 'high') { + return driftLevel; + } + } + } catch { + return undefined; + } + + return undefined; + } +} diff --git a/src/server/services/control-plane/session-runtime-context-service.ts b/src/server/services/control-plane/session-runtime-context-service.ts new file mode 100644 index 00000000..2a3aefb8 --- /dev/null +++ b/src/server/services/control-plane/session-runtime-context-service.ts @@ -0,0 +1,83 @@ +import { createConversationEngine } from '@/core/chat/engine/conversation-engine.js'; +import type { ConversationEngine, ConversationEngineConfig } from '@/core/chat/engine/types.js'; +import { resolveEffectiveReasoningEffort } from '@/core/chat/engine/sessions/preferences/service.js'; +import type { ChatSession } from '@/core/chat/types.js'; +import { DEFAULT_OPENAI_MODEL } from '@/core/config.js'; +import { ModelCatalogService, ModelPolicyService } from '@/core/llm/models/index.js'; +import { RuntimeCredentialService } from '@/core/runtime/credentials/index.js'; +import type { ChatSessionView, ControlPlaneSessionRuntimeContext } from '@/server/control-plane-types.js'; +import { ControlPlaneSessionDriftService } from './session-drift-service.js'; + +export type ControlPlaneSessionRuntimeContextArgs = Omit & { + model?: string; + sessionStoragePath: string; + workspaceId: string; + sessionId: string; +}; + +export type ControlPlaneSessionRuntimeContextOptions = { + driftLevel?: ChatSessionView['driftLevel']; + running?: boolean; +}; + +export type ControlPlaneResolvedSessionRuntimeContext = { + args: ControlPlaneSessionRuntimeContextArgs; + engine: ConversationEngine; + sessions: ConversationEngine['sessions']; + session: ChatSession; + runtimeContext: ControlPlaneSessionRuntimeContext; +}; + +/** + * Resolves selected-session runtime facts once for API views and command ports. + */ +export class ControlPlaneSessionRuntimeContextService { + read( + args: ControlPlaneSessionRuntimeContextArgs, + options: ControlPlaneSessionRuntimeContextOptions = {}, + ): ControlPlaneSessionRuntimeContext { + return this.resolve(args, options).runtimeContext; + } + + resolve( + args: ControlPlaneSessionRuntimeContextArgs, + options: ControlPlaneSessionRuntimeContextOptions = {}, + ): ControlPlaneResolvedSessionRuntimeContext { + const engine = createConversationEngine({ + ...args, + model: args.model ?? DEFAULT_OPENAI_MODEL, + }); + const sessions = engine.sessions; + const session = sessions.require(args.sessionId); + const model = session.model ?? args.model ?? DEFAULT_OPENAI_MODEL; + const estimatedInputTokens = session.context?.request?.usage?.inputTokens ?? session.context?.request?.estimatedTokens; + + return { + args, + engine, + sessions, + session, + runtimeContext: { + workspaceId: args.workspaceId, + sessionId: session.id, + sessionName: session.name, + model, + reasoningEffort: session.reasoningEffort, + effectiveReasoningEffort: resolveEffectiveReasoningEffort({ + model, + reasoningEffort: session.reasoningEffort, + }), + reasoningSupported: ModelPolicyService.supportsReasoningEffort(model), + credentialSource: RuntimeCredentialService.resolveCredentialSourceForModel(model, args), + contextWindow: ModelCatalogService.estimateBuiltInContextWindow(model), + estimatedInputTokens, + driftEnabled: session.driftEnabled ?? false, + driftLevel: options.driftLevel ?? ControlPlaneSessionDriftService.readLatestDriftLevel(session.turns), + compactionStatus: session.context?.compaction?.status, + running: options.running ?? false, + }, + }; + } +} + +export const controlPlaneSessionRuntimeContextService = new ControlPlaneSessionRuntimeContextService(); diff --git a/src/server/services/control-plane/slash-command-execution-context-service.ts b/src/server/services/control-plane/slash-command-execution-context-service.ts new file mode 100644 index 00000000..5f7188d2 --- /dev/null +++ b/src/server/services/control-plane/slash-command-execution-context-service.ts @@ -0,0 +1,149 @@ +import { ProviderCredentialRepository } from '@/core/auth/index.js'; +import { ChatSessionRecords } from '@/core/chat/engine/sessions/records/index.js'; +import type { ConversationEngineConfig } from '@/core/chat/engine/types.js'; +import type { ChatSession } from '@/core/chat/types.js'; +import type { ChatSessionLeaseOwner } from '@/core/chat/engine/sessions/leases/index.js'; +import type { SlashCommandExecutionContext } from '@/core/commands/slash/modules/context.js'; +import type { SlashCommandHint } from '@/core/commands/slash/types.js'; +import { FileHeartbeatTaskService } from '@/core/heartbeat/index.js'; +import type { LlmProvider } from '@/core/llm/types.js'; +import { controlPlaneSessionRuntimeContextService } from './session-runtime-context-service.js'; + +export type ControlPlaneSlashCommandExecutionContextArgs = Omit & { + model?: string; + sessionStoragePath: string; + workspaceId: string; + sessionId: string; + leaseOwner: ChatSessionLeaseOwner; + compactActive: () => Promise | string; +}; + +/** + * Composes core slash-command ports from resolved control-plane runtime context. + */ +export class ControlPlaneSlashCommandExecutionContextService { + create( + args: ControlPlaneSlashCommandExecutionContextArgs, + hints: SlashCommandHint[], + ): SlashCommandExecutionContext { + const resolved = controlPlaneSessionRuntimeContextService.resolve(args); + const { runtimeContext, sessions } = resolved; + const heartbeatTasks = new FileHeartbeatTaskService({ stateRoot: args.stateRoot }); + + return { + model: { + active: () => runtimeContext.model, + setActive: (model) => { + sessions.updateSettings(args.sessionId, { model }); + }, + activeReasoningEffort: () => runtimeContext.reasoningEffort, + setReasoningEffort: (reasoningEffort) => { + sessions.updateSettings(args.sessionId, { reasoningEffort }); + }, + credentialSource: () => runtimeContext.credentialSource, + }, + auth: { + status: () => this.formatAuthStatus(args.credentialStorePath), + login: async (provider) => { + throw new Error(`OAuth login for ${provider} is only available through the terminal auth command.`); + }, + logout: (provider) => this.logoutProvider(provider, args.credentialStorePath), + }, + compaction: { + compactActive: args.compactActive, + }, + drift: { + status: () => ({ enabled: runtimeContext.driftEnabled }), + setEnabled: (enabled) => { + sessions.setDriftEnabled(args.sessionId, enabled); + }, + }, + session: { + all: () => sessions.listExisting(), + recent: () => this.recentSessions(sessions.listExisting()), + recentListMessage: () => this.recentSessionMessages(sessions.listExisting()), + create: (name) => sessions.create({ + name, + model: runtimeContext.model, + workspaceId: args.workspaceId, + }), + switch: (id) => { + sessions.require(id); + }, + rename: (name) => { + sessions.rename(args.sessionId, name); + }, + remove: (id) => { + sessions.delete(id); + }, + clear: () => { + sessions.resetConversation(args.sessionId, { + apiKeyPresent: runtimeContext.credentialSource.type !== 'missing', + }); + }, + summarize: ChatSessionRecords.summarize, + }, + heartbeat: { + listTasks: async () => await heartbeatTasks.listTasks(), + listRunRecords: async (options) => await heartbeatTasks.listRunRecords(options), + loadRunRecord: async (id) => await heartbeatTasks.loadRunRecord(id), + }, + help: { + message: () => this.formatHelpMessage(hints), + }, + }; + } + + private recentSessions(sessions: ChatSession[]): ChatSession[] { + return [...sessions].sort((left, right) => (right.updatedAt ?? '').localeCompare(left.updatedAt ?? '')).slice(0, 10); + } + + private recentSessionMessages(sessions: ChatSession[]): string[] { + return this.recentSessions(sessions).map((session, index) => ( + `${index + 1}. ${session.id} (${session.name}) - ${ChatSessionRecords.summarize(session)}` + )); + } + + private formatAuthStatus(storePath = ProviderCredentialRepository.resolveStorePath()): string { + const summaries = new ProviderCredentialRepository({ storePath }).listSummaries(); + const lines = [`Auth store: ${storePath}`]; + if (summaries.length === 0) { + return [...lines, 'Stored credentials: none'].join('\n'); + } + + return [ + ...lines, + 'Stored credentials:', + ...summaries.map((summary) => { + const details = [ + `type=${summary.type}`, + summary.label ? `label=${summary.label}` : undefined, + summary.accountId ? `account=${summary.accountId}` : undefined, + summary.expiresAt ? `expires=${new Date(summary.expiresAt).toISOString()}` : undefined, + summary.expired === true ? 'expired=true' : undefined, + `updated=${summary.updatedAt}`, + ].filter(Boolean); + return `- ${summary.provider}: ${details.join(' ')}`; + }), + ].join('\n'); + } + + private logoutProvider(provider: LlmProvider, storePath = ProviderCredentialRepository.resolveStorePath()): string { + const removed = new ProviderCredentialRepository({ storePath }).remove(provider); + return removed ? `Removed stored ${provider} credential.` : `No stored ${provider} credential found.`; + } + + private formatHelpMessage(hints: SlashCommandHint[]): string { + return [ + 'Slash commands', + '', + ...hints.flatMap((hint) => [hint.command, capitalizeFirst(hint.description), '']), + ].join('\n').trimEnd(); + } +} + +export const controlPlaneSlashCommandExecutionContextService = new ControlPlaneSlashCommandExecutionContextService(); + +function capitalizeFirst(value: string): string { + return value.length > 0 ? `${value[0]?.toUpperCase()}${value.slice(1)}.` : value; +}