Merge remote-tracking branch 'origin/kimi-code-v2' into xtr/context-ops-wire

# Conflicts:
#	packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts
#	packages/agent-core-v2/src/agent/goal/goalService.ts
#	packages/agent-core-v2/src/agent/prompt/promptService.ts
#	packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts
This commit is contained in:
_Kerman 2026-07-04 18:14:31 +08:00
commit bec0b085f2
99 changed files with 313 additions and 172 deletions

View file

@ -1266,11 +1266,12 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
// 1. Authoritative id captured at submit time.
let promptId = rawState.promptIdBySession[sid];
// 2. Fallback to projector-derived id only when it is a real daemon prompt_id
// (synthetic `pr_...` ids are rejected by the daemon).
// 2. Fallback to projector-derived id only when it is a real daemon prompt_id.
// The v1 daemon uses `prompt_...`, server-v2 legacy uses `msg_...`;
// only local synthetic `pr_...` ids are rejected by the daemon.
if (promptId === undefined) {
const candidate = session?.currentPromptId;
if (candidate?.startsWith('prompt_')) {
if (candidate !== undefined && candidate.length > 0 && !candidate.startsWith('pr_')) {
promptId = candidate;
}
}

View file

@ -236,6 +236,32 @@ describe('useWorkspaceState — abortCurrentPrompt', () => {
expect(apiMock.abortPrompt).toHaveBeenCalledWith('sess_1', 'prompt_stale');
expect(apiMock.abortSession).not.toHaveBeenCalled();
});
it('uses a server-v2 msg prompt id recovered from session state', async () => {
apiMock.abortPrompt.mockResolvedValue({ aborted: true });
const state = createState();
state.promptIdBySession = {};
state.sessions = [{ ...state.sessions[0]!, currentPromptId: 'msg_live' }];
const workspace = useWorkspaceState(state, createDeps());
await workspace.abortCurrentPrompt();
expect(apiMock.abortPrompt).toHaveBeenCalledWith('sess_1', 'msg_live');
expect(apiMock.abortSession).not.toHaveBeenCalled();
});
it('does not send synthetic projector prompt ids to per-prompt abort', async () => {
apiMock.abortSession.mockResolvedValue({ aborted: true });
const state = createState();
state.promptIdBySession = {};
state.sessions = [{ ...state.sessions[0]!, currentPromptId: 'pr_synthetic' }];
const workspace = useWorkspaceState(state, createDeps());
await workspace.abortCurrentPrompt();
expect(apiMock.abortPrompt).not.toHaveBeenCalled();
expect(apiMock.abortSession).toHaveBeenCalledWith('sess_1');
});
});
describe('mergeWorkspaces', () => {

View file

@ -19,6 +19,7 @@ export interface AgentBlobServiceOptions {
export interface IAgentBlobService {
readonly _serviceBrand: undefined;
offloadParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]>;
rehydrateParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]>;
isBlobRef(url: string): boolean;

View file

@ -18,6 +18,7 @@ export type ContextInjectionProvider = (
export interface IAgentContextInjectorService {
readonly _serviceBrand: undefined;
register(
variant: string,
provider: ContextInjectionProvider,

View file

@ -5,6 +5,7 @@ import type { ContextMessage } from '#/agent/contextMemory';
export interface IAgentContextProjectorService {
readonly _serviceBrand: undefined;
project(messages: readonly ContextMessage[]): readonly Message[];
}

View file

@ -35,6 +35,7 @@ export interface AgentTaskStopHookContext {
export interface IAgentExternalHooksService {
readonly _serviceBrand: undefined;
/**
* Run the blocking `SubagentStart` external hook for an agent task this
* agent is launching (via the `Agent` tool / swarm). Called directly by the

View file

@ -25,14 +25,15 @@ export interface FullCompactionDidCompactContext {
export interface IAgentFullCompactionService {
readonly _serviceBrand: undefined;
readonly isCompacting: boolean;
begin(input: CompactInput): boolean;
cancel(): void;
readonly hooks: Hooks<{
onWillCompact: FullCompactionWillCompactContext;
onDidCompact: FullCompactionDidCompactContext;
}>;
readonly isCompacting: boolean;
begin(input: CompactInput): boolean;
cancel(): void;
}
export const IAgentFullCompactionService = createDecorator<IAgentFullCompactionService>('agentFullCompactionService');

View file

@ -13,6 +13,7 @@ export interface GoalReasonInput {
export interface IAgentGoalService {
readonly _serviceBrand: undefined;
getGoal(): GoalToolResult;
createGoal(input: CreateGoalInput, actor?: GoalActor): Promise<GoalSnapshot>;
pauseGoal(input?: GoalReasonInput, actor?: GoalActor): Promise<GoalSnapshot>;

View file

@ -504,7 +504,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
origin: GOAL_CONTINUATION_ORIGIN,
});
this.contextOps.append(message);
this.turnService.launch(GOAL_CONTINUATION_ORIGIN);
this.turnService.launch();
}
private normalizeAfterReplay(): void {

View file

@ -100,6 +100,7 @@ export interface LLMRequestOverrides {
export interface IAgentLLMRequesterService {
readonly _serviceBrand: undefined;
request(
overrides?: LLMRequestOverrides,
onPart?: LLMRequestPartHandler,

View file

@ -28,10 +28,11 @@ export interface TurnErrorContext {
retry: boolean;
}
export interface RunTurnOptions {
export interface RunOptions {
readonly turnId: number;
readonly signal?: AbortSignal;
/** Fires on the first model response event for a step, or at step completion. */
readonly onStepStarted?: (step: number) => void;
readonly onStarted?: (step: number) => void;
}
export interface TurnResult {
@ -42,12 +43,14 @@ export interface TurnResult {
export interface IAgentLoopService {
readonly _serviceBrand: undefined;
run(options: RunOptions): Promise<TurnResult>;
readonly hooks: Hooks<{
beforeStep: TurnBeforeStepContext;
afterStep: TurnAfterStepContext;
onError: TurnErrorContext;
}>;
runTurn(turnId: number, options?: RunTurnOptions): Promise<TurnResult>;
}
export const IAgentLoopService = createDecorator<IAgentLoopService>('agentLoopService');

View file

@ -28,7 +28,7 @@ import {
} from './errors';
import {
IAgentLoopService,
type RunTurnOptions,
type RunOptions,
type TurnAfterStepContext,
type TurnResult,
} from './loop';
@ -58,10 +58,8 @@ export class AgentLoopService implements IAgentLoopService {
@IConfigService private readonly config: IConfigService,
) { }
async runTurn(
turnId: number,
options: RunTurnOptions = {},
): Promise<TurnResult> {
async run(options: RunOptions): Promise<TurnResult> {
const { turnId } = options;
const signal = options.signal ?? new AbortController().signal;
let steps = 0;
@ -83,7 +81,7 @@ export class AgentLoopService implements IAgentLoopService {
turnId,
signal,
steps,
options.onStepStarted,
options.onStarted,
);
activeStep = undefined;
@ -135,7 +133,7 @@ export class AgentLoopService implements IAgentLoopService {
turnId: number,
signal: AbortSignal,
currentStep: number,
onStepStarted: ((step: number) => void) | undefined,
onStarted: ((step: number) => void) | undefined,
): Promise<{
readonly stopReason: FinishReason;
readonly continue: boolean;
@ -151,7 +149,7 @@ export class AgentLoopService implements IAgentLoopService {
const markStepStarted = (): void => {
if (stepStarted) return;
stepStarted = true;
onStepStarted?.(currentStep);
onStarted?.(currentStep);
};
const emitStreamPart = this.createStreamPartHandler(turnId, markStepStarted);
const response = await this.llmRequester.request(

View file

@ -16,8 +16,8 @@ export interface McpResolvedServer {
export interface IAgentMcpService {
readonly _serviceBrand: undefined;
readonly oauthService: McpOAuthService | undefined;
readonly oauthService: McpOAuthService | undefined;
waitForInitialLoad(signal?: AbortSignal): Promise<void>;
initialLoadDurationMs(): number;
list(): readonly McpServerEntry[];

View file

@ -24,6 +24,7 @@ export interface MicroCompactionEffect {
export interface IAgentMicroCompactionService {
readonly _serviceBrand: undefined;
compact(messages: readonly ContextMessage[]): readonly ContextMessage[];
}

View file

@ -44,14 +44,16 @@ export type PermissionApprovalResultContext =
export interface IAgentPermissionGate {
readonly _serviceBrand: undefined;
readonly hooks: Hooks<{
onDidRequestApproval: PermissionApprovalRequestContext;
onDidResolveApproval: PermissionApprovalResultContext;
}>;
data(): PermissionData;
authorize(
context: ResolvedToolExecutionHookContext,
): Promise<AuthorizeToolExecutionResult | undefined>;
readonly hooks: Hooks<{
onDidRequestApproval: PermissionApprovalRequestContext;
onDidResolveApproval: PermissionApprovalResultContext;
}>;
}
export const IAgentPermissionGate =

View file

@ -10,6 +10,7 @@ export interface PermissionModeChangedContext {
export interface IAgentPermissionModeService {
readonly _serviceBrand: undefined;
readonly mode: PermissionMode;
setMode(mode: PermissionMode): void;

View file

@ -12,6 +12,7 @@ export interface PermissionPolicyEvaluation {
export interface IAgentPermissionPolicyService {
readonly _serviceBrand: undefined;
evaluate(
context: ResolvedToolExecutionHookContext,
): Promise<PermissionPolicyEvaluation | undefined>;

View file

@ -43,9 +43,9 @@ export interface PermissionRule {
export interface IAgentPermissionRulesService {
readonly _serviceBrand: undefined;
readonly rules: readonly PermissionRule[];
readonly sessionApprovalRulePatterns: readonly string[];
addRules(rules: readonly PermissionRule[]): void;
recordApprovalResult(record: PermissionApprovalResultRecord): void;

View file

@ -10,6 +10,7 @@ export type PlanFilePath = string | null;
export interface IAgentPlanService {
readonly _serviceBrand: undefined;
enter(id?: string, createFile?: boolean): Promise<void>;
cancel(id?: string): void;
clear(): Promise<void>;

View file

@ -109,6 +109,7 @@ export interface BindAgentInput {
export interface IAgentProfileService {
readonly _serviceBrand: undefined;
configure(options: ProfileServiceOptions): void;
update(changed: ProfileUpdateData): void;
/**

View file

@ -16,6 +16,7 @@ export interface PromptSteerHandle {
export interface IAgentPromptService {
readonly _serviceBrand: undefined;
prompt(message: ContextMessage): Promise<Turn | undefined>;
steer(message: ContextMessage): PromptSteerHandle;
retry(trigger?: string): Turn | undefined;

View file

@ -5,10 +5,8 @@ import { ErrorCodes, KimiError } from '#/errors';
import {
ensureMessageId,
IAgentContextMemoryService,
USER_PROMPT_ORIGIN,
type ContextMessage,
type ContextOperation,
type PromptOrigin,
} from '#/agent/contextMemory';
import {
IAgentContextOpsService,
@ -78,7 +76,7 @@ export class AgentPromptService implements IAgentPromptService {
const stamped = ensureMessageId(message);
this.contextOps.append(stamped);
if (await this.blockedByHook(stamped, false)) return undefined;
return this.launch(stamped.origin ?? USER_PROMPT_ORIGIN);
return this.launch();
}
steer(message: ContextMessage): PromptSteerHandle {
@ -104,7 +102,7 @@ export class AgentPromptService implements IAgentPromptService {
}
retry(trigger?: string): Turn | undefined {
return this.launch({ kind: 'retry', trigger });
return this.launch();
}
undo(count: number): number {
@ -154,8 +152,8 @@ export class AgentPromptService implements IAgentPromptService {
this.contextOps.clear();
}
private launch(origin: PromptOrigin): Turn {
const turn = this.turnService.launch(origin);
private launch(): Turn {
const turn = this.turnService.launch();
this.observe(turn);
return turn;
}

View file

@ -22,6 +22,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio
export interface IAgentPromptLegacyService {
readonly _serviceBrand: undefined;
list(): PromptListResponse;
submit(body: PromptSubmission): Promise<PromptSubmitResult>;
steer(promptIds: readonly string[]): Promise<PromptSteerResult>;

View file

@ -97,7 +97,6 @@ export interface IAgentRecordService {
* Returns a disposable that unregisters the facets (and the resumer).
*/
define<K extends keyof AgentRecordMap>(type: K, facets: RecordFacets<K>): IDisposable;
/**
* Append a record to the replay read model directly. Used when the projected
* data is computed inside a domain handler rather than derived from a single
@ -125,9 +124,9 @@ export interface IAgentRecordService {
buildReplay(): readonly AgentReplayRecord[];
/** When true, live `append` calls also feed the replay read model. */
captureLiveRecords: boolean;
readonly restoring: WireRecordRestoringContext | null;
readonly postRestoring: boolean;
readonly hooks: IAgentWireRecordService['hooks'];
}

View file

@ -6,10 +6,12 @@ import type {
import type { PromisableMethods } from "#/_base/utils/types";
export interface IAgentRPCService extends PromisableMethods<AgentAPI> {
readonly _serviceBrand: undefined;}
readonly _serviceBrand: undefined;
}
export interface ISessionRPCService extends PromisableMethods<SessionAPI> {
readonly _serviceBrand: undefined;}
readonly _serviceBrand: undefined;
}
export const IAgentRPCService =
createDecorator<IAgentRPCService>('agentRPCService');

View file

@ -14,8 +14,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio
export interface IAgentScopeContext {
readonly _serviceBrand: undefined;
readonly agentId: string;
readonly agentId: string;
/**
* Persistence scope rooted at this agent. `scope()` returns the agent
* scope itself; `scope(subKey)` returns `${agentScope}/${subKey}` (e.g.

View file

@ -11,7 +11,6 @@ export interface IAgentSkillService {
readonly _serviceBrand: undefined;
activate(input: SkillActivationInput): Promise<Turn>;
/**
* Records a model-tool skill activation (an inline skill loaded through the
* `Skill` tool) without opening a new turn the tool builds and steers its

View file

@ -4,6 +4,7 @@ export type SwarmModeTrigger = 'manual' | 'task' | 'tool';
export interface IAgentSwarmService {
readonly _serviceBrand: undefined;
readonly isActive: boolean;
enter(trigger: SwarmModeTrigger): void;
exit(): void;

View file

@ -98,16 +98,10 @@ export interface AgentTaskNotificationContext {
export interface IAgentTaskService {
readonly _serviceBrand: undefined;
readonly hooks: Hooks<{
onDidNotify: AgentTaskNotificationContext;
}>;
/** Track a `ITaskHandle` (from `taskService.run()`). */
track(handle: ITaskHandle, options: AgentTaskTrackOptions): IAgentTaskEntry;
/** @deprecated Use `taskService.run()` + `track()` instead. */
registerTask(task: AgentTask, options?: RegisterAgentTaskOptions): string;
getTask(taskId: string): AgentTaskInfo | undefined;
list(activeOnly?: boolean, limit?: number): readonly AgentTaskInfo[];
persistOutput(taskId: string): void;
@ -124,6 +118,10 @@ export interface IAgentTaskService {
waitForForegroundRelease(
taskId: string,
): Promise<ForegroundTaskReleaseReason | undefined>;
readonly hooks: Hooks<{
onDidNotify: AgentTaskNotificationContext;
}>;
}
export const IAgentTaskService =

View file

@ -36,6 +36,7 @@ export type ToolDedupResult = ToolDedupSuccessResult | ToolDedupErrorResult;
export interface IAgentToolDedupeService {
readonly _serviceBrand: undefined;
readonly currentStreak: number;
}

View file

@ -18,6 +18,7 @@ export interface ToolRegistrationOptions {
export interface IAgentToolRegistryService {
readonly _serviceBrand: undefined;
register(tool: ExecutableTool, options?: ToolRegistrationOptions): IDisposable;
list(): readonly ToolInfo[];
resolve(name: string): ExecutableTool | undefined;

View file

@ -17,6 +17,7 @@ export interface ToolStoreUpdate<K extends ToolStoreKey = ToolStoreKey> {
export interface IAgentToolState extends ToolStore {
readonly _serviceBrand: undefined;
data(): Readonly<Partial<ToolStoreData>>;
readonly hooks: Hooks<{

View file

@ -1,6 +1,5 @@
import { createDecorator } from "#/_base/di";
import type { TurnResult } from '#/agent/loop';
import type { PromptOrigin } from '#/agent/contextMemory';
import type { Hooks } from '#/hooks';
export type { TurnResult } from '#/agent/loop';
@ -23,7 +22,8 @@ export interface TurnEndedContext {
export interface IAgentTurnService {
readonly _serviceBrand: undefined;
launch(origin: PromptOrigin): Turn;
launch(): Turn;
getActiveTurn(): Turn | undefined;
readonly hooks: Hooks<{

View file

@ -3,7 +3,6 @@ import { createControlledPromise } from '@antfu/utils';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ErrorCodes, KimiError, toKimiErrorPayload } from '#/errors';
import type { PromptOrigin } from '#/agent/contextMemory';
import { OrderedHookSlot } from '#/hooks';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentTelemetryContextService, ITelemetryService } from '#/app/telemetry';
@ -19,7 +18,6 @@ declare module '#/agent/wireRecord' {
interface WireRecordMap {
'turn.launch': {
turnId: number;
origin: PromptOrigin;
};
}
}
@ -47,7 +45,7 @@ export class AgentTurnService implements IAgentTurnService {
});
}
launch(origin: PromptOrigin): Turn {
launch(): Turn {
if (this.activeTurn !== undefined) {
throw new KimiError(
ErrorCodes.TURN_AGENT_BUSY,
@ -57,7 +55,7 @@ export class AgentTurnService implements IAgentTurnService {
}
const turnId = this.nextTurnId;
this.record.append({ type: 'turn.launch', turnId, origin });
this.record.append({ type: 'turn.launch', turnId });
this.restoreLaunch(turnId);
const abortController = new AbortController();
const ready = createControlledPromise<void>();
@ -69,7 +67,7 @@ export class AgentTurnService implements IAgentTurnService {
};
void ready.catch(() => undefined);
this.activeTurn = turn;
turn.result = this.runTurn(turn, origin, ready);
turn.result = this.runTurn(turn, ready);
void this.hooks.onLaunched.run({ turn });
return turn;
}
@ -80,7 +78,6 @@ export class AgentTurnService implements IAgentTurnService {
private async runTurn(
turn: Turn,
origin: PromptOrigin,
ready: ReturnType<typeof createControlledPromise<void>>,
): Promise<TurnResult> {
const startedAt = Date.now();
@ -91,11 +88,11 @@ export class AgentTurnService implements IAgentTurnService {
this.record.signal({
type: 'turn.started',
turnId: turn.id,
origin,
});
result = await this.loop.runTurn(turn.id, {
result = await this.loop.run({
turnId: turn.id,
signal: turn.abortController.signal,
onStepStarted: () => ready.resolve(),
onStarted: () => ready.resolve(),
});
return result;
} catch (error) {

View file

@ -11,6 +11,7 @@ export interface UsageStatus {
export interface IAgentUsageService {
readonly _serviceBrand: undefined;
record(model: string, usage: TokenUsage, source?: LLMRequestSource): void;
status(): UsageStatus;
}

View file

@ -63,9 +63,9 @@ export interface WireRecordServiceOptions {
export interface IAgentWireRecordService {
readonly _serviceBrand: undefined;
readonly restoring: WireRecordRestoringContext | null;
readonly postRestoring: boolean;
append(record: WireRecord): void;
/**
* Snapshot of every record currently held in memory (live-appended and

View file

@ -102,6 +102,7 @@ export interface AgentProfile {
export interface IAgentProfileCatalogService {
readonly _serviceBrand: undefined;
/** Return the profile with the given name, or `undefined` when unknown. */
get(name: string): AgentProfile | undefined;
/**

View file

@ -36,6 +36,7 @@ export interface AuthStatus {
export interface IOAuthService {
readonly _serviceBrand: undefined;
startLogin(provider?: string): Promise<OAuthFlowStart>;
getFlow(provider?: string): OAuthFlowSnapshot | undefined;
cancelLogin(provider?: string): Promise<OAuthLoginCancelResponse>;
@ -51,6 +52,7 @@ export const IOAuthService: ServiceIdentifier<IOAuthService> =
export interface IOAuthToolkit {
readonly _serviceBrand: undefined;
login(providerName?: string, options?: KimiOAuthLoginOptions): Promise<KimiOAuthLoginResult>;
logout(providerName?: string, oauthRef?: KimiOAuthTokenRef): Promise<KimiOAuthLogoutResult>;
getCachedAccessToken(
@ -65,6 +67,7 @@ export const IOAuthToolkit: ServiceIdentifier<IOAuthToolkit> =
export interface IAuthSummaryService {
readonly _serviceBrand: undefined;
summarize(): Promise<readonly AuthStatus[]>;
ensureReady(provider?: string): Promise<void>;
}

View file

@ -24,6 +24,7 @@ export interface WebSearchProviderOptions {
export interface IWebSearchProviderService {
readonly _serviceBrand: undefined;
getWebSearchProvider(): WebSearchProvider | undefined;
}

View file

@ -64,7 +64,6 @@ export interface IBootstrapService {
readonly arch: string;
readonly cwd: string;
readonly osHomeDir: string;
readonly homeDir: string;
readonly configPath: string;
readonly sessionsDir: string;
@ -72,40 +71,33 @@ export interface IBootstrapService {
readonly storeDir: string;
readonly cacheDir: string;
readonly logsDir: string;
getEnv(name: string): string | undefined;
/**
* Scope string for a well-known top-level persistence area. Business code
* passes this to `IFileSystemStorageService` / `IAtomicDocumentStore` / `IAppendLogStore`
* the backend layer converts it to concrete addressing.
*/
scope(name: PersistenceScopeName): string;
/**
* Scope string for a session's persistence root.
* Equivalent to `${scope('sessions')}/${workspaceId}/${sessionId}`.
*/
sessionScope(workspaceId: string, sessionId: string): string;
/**
* Scope string for a specific agent's persistence root under a session.
* Equivalent to `${sessionScope(wsId, sId)}/agents/${agentId}`.
*/
agentScope(workspaceId: string, sessionId: string, agentId: string): string;
/**
* File-only: absolute on-disk directory for a session.
* Prefer `sessionScope(...)` this exists for legacy APIs (session logs,
* task output files). Non-file bootstraps may throw.
*/
sessionDir(workspaceId: string, sessionId: string): string;
/**
* File-only: absolute on-disk directory for a specific agent. Same caveat.
*/
agentHomedir(workspaceId: string, sessionId: string, agentId: string): string;
/** Key of the config document under `scope('config')` (file: `'config.toml'`). */
readonly configKey: string;
}

View file

@ -81,7 +81,6 @@ export interface IConfigRegistry {
readonly onDidRegisterSection: Event<ConfigSectionRegisteredEvent>;
readonly onDidRegisterOverlay: Event<ConfigOverlayRegisteredEvent>;
registerSection<T>(domain: string, schema: ConfigSchema<T>, options?: RegisterSectionOptions<T>): void;
getSection(domain: string): ConfigSection | undefined;
listSections(): readonly ConfigSection[];
@ -147,10 +146,10 @@ export interface ConfigInspectValue<T = unknown> {
export interface IConfigService {
readonly _serviceBrand: undefined;
readonly ready: Promise<void>;
readonly onDidChangeConfiguration: Event<ConfigChangedEvent>;
readonly onDidSectionChange: Event<ConfigSectionChangedEvent>;
get<T = unknown>(domain: string): T;
inspect<T = unknown>(domain: string): ConfigInspectValue<T>;
getAll(): ResolvedConfig;

View file

@ -18,6 +18,7 @@ export interface CronTaskQuery {
export interface ICronTaskPersistence {
readonly _serviceBrand: undefined;
get(workspaceId: string, taskId: string): Promise<CronTask | undefined>;
list(query: CronTaskQuery): Promise<readonly CronTask[]>;
save(workspaceId: string, task: CronTask): Promise<void>;

View file

@ -16,6 +16,7 @@ export interface DomainEvent {
export interface IEventService {
readonly _serviceBrand: undefined;
readonly onDidPublish: Event<DomainEvent>;
publish(event: DomainEvent): void;
subscribe(handler: (event: DomainEvent) => void): IDisposable;

View file

@ -35,9 +35,7 @@ export interface IFileService {
readonly _serviceBrand: undefined;
save(source: Readable, filename: string, options?: SaveOptions): Promise<FileMeta>;
get(fileId: string): Promise<GetResult>;
delete(fileId: string): Promise<void>;
}

View file

@ -61,6 +61,7 @@ export interface ExperimentalFeatureState {
export interface IFlagService {
readonly _serviceBrand: undefined;
readonly registry: IFlagRegistry;
enabled(id: FlagId): boolean;
snapshot(): ExperimentalFlagMap;

View file

@ -38,9 +38,7 @@ export interface IFlagRegistry {
readonly _serviceBrand: undefined;
register(definition: FlagDefinitionInput): IDisposable;
get(id: FlagId): FlagDefinitionInput | undefined;
list(): readonly FlagDefinitionInput[];
}

View file

@ -11,6 +11,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio
export interface IRestGateway {
readonly _serviceBrand: undefined;
prompt(
sessionId: string,
agentId: string,
@ -32,6 +33,7 @@ export const IRestGateway: ServiceIdentifier<IRestGateway> =
export interface IWSGateway {
readonly _serviceBrand: undefined;
connect(connectionId: string): void;
broadcast(sessionId: string, event: unknown): void;
}

View file

@ -23,7 +23,6 @@ export interface IGitService {
* tree or git itself fails.
*/
status(cwd: string, pathFilter?: ReadonlySet<string>): Promise<FsGitStatusResponse>;
/**
* `git diff HEAD -- <relPath>` for the repo at `cwd`. `relPath` is the
* repo-relative posix path passed to git; `absPath` is the confined absolute

View file

@ -14,7 +14,6 @@ export interface IGlobalSkillCatalog {
readonly _serviceBrand: undefined;
readonly catalog: SkillCatalog;
load(): Promise<void>;
}

View file

@ -26,7 +26,6 @@ export interface ISkillDiscovery {
workDir: string,
extraRoots?: readonly SkillRoot[],
): Promise<SkillDiscoveryResult>;
discoverUser(homeDir: string, osHomeDir: string): Promise<SkillDiscoveryResult>;
}

View file

@ -57,7 +57,6 @@ export interface IHostFolderBrowser {
* target.
*/
browse(absPath?: string): Promise<FsBrowseResponse>;
/** `$HOME` plus the most recently opened workspace roots. */
home(): Promise<FsHomeResponse>;
}

View file

@ -31,6 +31,7 @@ export interface LogEntry {
export interface ILogWriterService {
readonly _serviceBrand: undefined;
write(entry: LogEntry): void;
flush?(): Promise<void>;
close?(): Promise<void>;
@ -49,6 +50,7 @@ export interface ILogger {
export interface ILogService extends ILogger {
readonly _serviceBrand: undefined;
readonly level: LogLevel;
setLevel(level: LogLevel): void;
flush(): Promise<void>;
@ -72,6 +74,7 @@ export function levelEnabled(level: LogLevel, configured: LogLevel): boolean {
export interface ISessionLogService extends ILogger {
readonly _serviceBrand: undefined;
flush(): Promise<void>;
close(): Promise<void>;
}

View file

@ -41,7 +41,6 @@ export interface IMessageLegacyService {
* Throws `session.not_found` when `sid` is unknown.
*/
list(sessionId: string, query: MessageListQuery): Promise<PageResponse<Message>>;
/**
* `GET /sessions/{sid}/messages/{mid}` single message by id.
* Throws `session.not_found` when `sid` is unknown, `message.not_found` when

View file

@ -90,6 +90,7 @@ export interface ModelsChangedEvent {
export interface IModelService {
readonly _serviceBrand: undefined;
readonly onDidChangeModels: Event<ModelsChangedEvent>;
get(id: string): ModelConfig | undefined;
list(): Readonly<Record<string, ModelConfig>>;

View file

@ -20,6 +20,7 @@ import type { Model } from './modelInstance';
export interface IModelResolver {
readonly _serviceBrand: undefined;
/** Resolve a Model id into a runnable god-object Model instance. */
resolve(id: string): Model;
/** All Model ids whose `name` or `aliases` match the given routing key. */

View file

@ -65,6 +65,7 @@ export interface PlatformsChangedEvent {
export interface IPlatformService {
readonly _serviceBrand: undefined;
readonly onDidChangePlatforms: Event<PlatformsChangedEvent>;
get(name: string): PlatformConfig | undefined;
list(): Readonly<Record<string, PlatformConfig>>;

View file

@ -38,6 +38,7 @@ export interface GetPluginInfoInput {
export interface IPluginService {
readonly _serviceBrand: undefined;
listPlugins(): Promise<readonly PluginSummary[]>;
installPlugin(input: InstallPluginInput): Promise<PluginSummary>;
setPluginEnabled(input: SetPluginEnabledInput): Promise<void>;
@ -47,7 +48,6 @@ export interface IPluginService {
getPluginInfo(input: GetPluginInfoInput): Promise<PluginInfo | undefined>;
listPluginCommands(): Promise<readonly PluginCommandDef[]>;
checkUpdates(): Promise<readonly PluginUpdateStatus[]>;
// --- consumption plane (loaded from enabled, error-free plugins) ---------
/** Skill roots contributed by enabled plugins (fed into skill discovery). */
@ -58,7 +58,6 @@ export interface IPluginService {
enabledMcpServers(): Promise<Record<string, McpServerConfig>>;
/** Hooks contributed by enabled plugins (cwd + env already resolved). */
enabledHooks(): Promise<readonly HookDef[]>;
/** Fires after a successful `reloadPlugins()` with the reload summary. */
readonly onDidReload: Event<ReloadSummary>;
}

View file

@ -48,6 +48,7 @@ export interface ProtocolAdapterConfig {
export interface IProtocolAdapterRegistry {
readonly _serviceBrand: undefined;
/** Protocols this registry can build adapters for. */
supportedProtocols(): readonly Protocol[];
}

View file

@ -89,6 +89,7 @@ export interface ProvidersChangedEvent {
export interface IProviderService {
readonly _serviceBrand: undefined;
readonly onDidChangeProviders: Event<ProvidersChangedEvent>;
get(name: string): ProviderConfig | undefined;
list(): Readonly<Record<string, ProviderConfig>>;

View file

@ -41,6 +41,7 @@ export interface SessionListQuery {
export interface ISessionIndex {
readonly _serviceBrand: undefined;
/** List persisted sessions, optionally filtered by workspace. */
list(query: SessionListQuery): Promise<Page<SessionSummary>>;
/** Fetch a single persisted session by id. */

View file

@ -58,6 +58,7 @@ export interface SessionChildrenPage {
export interface ISessionLegacyService {
readonly _serviceBrand: undefined;
fork(sessionId: string, body: ForkSessionRequest): Promise<SessionWireFields>;
createChild(sessionId: string, body: CreateSessionChildRequest): Promise<SessionWireFields>;
listChildren(sessionId: string, query: SessionChildrenQuery): Promise<SessionChildrenPage>;

View file

@ -51,6 +51,7 @@ export interface SessionForkedEvent {
export interface ISessionLifecycleService {
readonly _serviceBrand: undefined;
readonly onDidCreateSession: Event<SessionCreatedEvent>;
readonly onDidCloseSession: Event<SessionClosedEvent>;
readonly onDidArchiveSession: Event<SessionArchivedEvent>;

View file

@ -57,7 +57,6 @@ export interface ITaskService {
* State: pending running completed | failed | cancelled.
*/
run<T>(fn: (signal: AbortSignal, output: (data: string) => void) => Promise<T>): ITaskHandle<T>;
/**
* Create a passive task whose settlement is controlled by the caller
* through the returned `resolve` / `reject` methods.

View file

@ -13,6 +13,7 @@ import type { TelemetryProperties } from './telemetry';
export interface IAgentTelemetryContextService {
readonly _serviceBrand: undefined;
/** Current ambient telemetry properties for this agent. */
get(): TelemetryProperties;
/** Merge a patch into the ambient telemetry context. */

View file

@ -37,6 +37,7 @@ export interface TelemetryServiceOptions {
export interface ITelemetryService {
readonly _serviceBrand: undefined;
track(event: string, properties?: TelemetryProperties): void;
withContext(patch: TelemetryContextPatch): ITelemetryService;
setContext(patch: TelemetryContextPatch): void;

View file

@ -23,6 +23,7 @@ export interface WebFetchServiceOptions {
export interface IWebFetchService {
readonly _serviceBrand: undefined;
getUrlFetcher(): UrlFetcher;
}

View file

@ -41,7 +41,6 @@ export interface IWorkspacePersistence {
* - `Workspace[]` (possibly empty) a materialized catalog; do not rebuild.
*/
load(): Promise<Workspace[] | undefined>;
/** Atomically replace the persisted catalog. */
save(workspaces: readonly Workspace[]): Promise<void>;
}

View file

@ -47,7 +47,6 @@ export interface IHostEnvironment {
readonly pathClass: PathClass;
/** Absolute path of the current user's home directory (`os.homedir()`). */
readonly homeDir: string;
/**
* Resolves once the probe has completed. Every field above is populated by
* the time this promise settles. The composition root awaits this before

View file

@ -45,7 +45,6 @@ export interface IHostProcess {
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
/** Wait for the process to exit and return its exit code. */
wait(): Promise<number>;
/** Kill the process tree (not just the direct child) with the given signal. */

View file

@ -34,15 +34,10 @@ export interface IAppendLogStore {
readonly _serviceBrand: undefined;
append<R>(scope: string, key: string, record: R, options?: AppendLogOptions): void;
read<R>(scope: string, key: string): AsyncIterable<R>;
rewrite<R>(scope: string, key: string, records: readonly R[]): Promise<void>;
flush(): Promise<void>;
close(): Promise<void>;
acquire(scope: string, key: string): IDisposable;
}

View file

@ -22,15 +22,10 @@ export interface IAtomicDocumentStore {
readonly _serviceBrand: undefined;
get<T>(scope: string, key: string): Promise<T | undefined>;
set<T>(scope: string, key: string, value: T): Promise<void>;
delete(scope: string, key: string): Promise<void>;
list(scope: string, prefix?: string): Promise<readonly string[]>;
watch(scope: string, key: string): Event<void>;
acquire(scope: string, key: string): IDisposable;
}

View file

@ -15,15 +15,10 @@ export interface IBlobStore {
readonly _serviceBrand: undefined;
put(scope: string, key: string, data: Uint8Array): Promise<void>;
get(scope: string, key: string): Promise<Uint8Array | undefined>;
getStream(scope: string, key: string): AsyncIterable<Uint8Array>;
has(scope: string, key: string): Promise<boolean>;
delete(scope: string, key: string): Promise<void>;
list(scope: string, prefix?: string): Promise<readonly string[]>;
}

View file

@ -83,21 +83,13 @@ export interface IQueryStore {
readonly _serviceBrand: undefined;
put<T>(collection: string, key: string, value: T): Promise<void>;
batch(ops: readonly WriteOp[]): Promise<void>;
delete(collection: string, key: string): Promise<void>;
get<T>(collection: string, key: string): Promise<T | undefined>;
query<T>(collection: string): IQuery<T>;
ensureIndex(collection: string, def: IndexDef): Promise<void>;
getCheckpoint(source: string): Promise<Checkpoint | undefined>;
setCheckpoint(source: string, checkpoint: Checkpoint): Promise<void>;
close(): Promise<void>;
}

View file

@ -41,21 +41,13 @@ export interface IFileSystemStorageService {
readonly _serviceBrand: undefined;
read(scope: string, key: string): Promise<Uint8Array | undefined>;
readStream(scope: string, key: string): AsyncIterable<Uint8Array>;
write(scope: string, key: string, data: Uint8Array, options?: StorageWriteOptions): Promise<void>;
append(scope: string, key: string, data: Uint8Array, options?: StorageAppendOptions): Promise<void>;
list(scope: string, prefix?: string): Promise<readonly string[]>;
delete(scope: string, key: string): Promise<void>;
watch?(scope: string, key: string): Event<void>;
flush(): Promise<void>;
close(): Promise<void>;
}

View file

@ -86,6 +86,7 @@ export interface AgentListFilter {
export interface IAgentLifecycleService {
readonly _serviceBrand: undefined;
/** Fires after an agent is created and registered, with its scope handle. */
readonly onDidCreate: Event<IAgentScopeHandle>;
/**

View file

@ -32,6 +32,7 @@ export interface ApprovalResponse {
export interface ISessionApprovalService {
readonly _serviceBrand: undefined;
request(req: ApprovalRequest): Promise<ApprovalResponse>;
/**
* Submit an approval request without blocking on the decision. Returns the

View file

@ -34,6 +34,7 @@ IMPORTANT:
export interface ISessionBtwService {
readonly _serviceBrand: undefined;
/**
* Fork the main agent into a side-question child agent (tools disabled,
* side-channel reminder appended) and return the child's id.

View file

@ -20,18 +20,16 @@ export interface CronLoadOptions {
export interface ISessionCronService {
readonly _serviceBrand: undefined;
readonly isEnabled: boolean;
readonly isEnabled: boolean;
addTask(init: CronTaskInit): CronTask;
removeTasks(ids: readonly string[]): readonly string[];
getTask(id: string): CronTask | undefined;
list(): readonly CronTask[];
now(): number;
isStale(task: CronTask): boolean;
getNextFireTime(): number | null;
getNextFireForTask(taskId: string): number | null;
loadFromStore(options?: CronLoadOptions): Promise<void>;
start(): void;
stop(): Promise<void>;
@ -41,7 +39,6 @@ export interface ISessionCronService {
tasks: readonly CronTask[],
renderMissedNotification: (tasks: readonly CronTask[]) => readonly ContentPart[],
): Turn | undefined;
emitScheduled(task: CronTask): void;
emitDeleted(taskId: string): void;
}

View file

@ -26,15 +26,12 @@ export interface IExecContext {
/** Absolute path to the session's working directory. */
readonly cwd: string;
/** Ordered list of env overlays applied on top of `process.env` when
* spawning a process. Later layers win. */
readonly envLayers: readonly Record<string, string>[];
/** Return a new `IExecContext` rooted at `cwd`, keeping the same env
* layers. Does not mutate this context. */
withCwd(cwd: string): IExecContext;
/** Return a new `IExecContext` with `env` appended to `envLayers`. Does
* not mutate this context. */
withEnv(env: Record<string, string>): IExecContext;

View file

@ -51,6 +51,7 @@ export interface InteractionPendingChangedEvent {
export interface ISessionInteractionService {
readonly _serviceBrand: undefined;
request<TPayload, TResponse>(req: InteractionRequest<TPayload>): Promise<TResponse>;
/**
* Park a request without blocking on its response. Returns the created

View file

@ -53,6 +53,7 @@ export interface QuestionRequest {
export interface ISessionQuestionService {
readonly _serviceBrand: undefined;
request(req: QuestionRequest): Promise<QuestionResult>;
/**
* Post a question without blocking on the answer. Returns the request with

View file

@ -13,6 +13,7 @@ export type { SessionWarning };
export interface ISessionWarningService {
readonly _serviceBrand: undefined;
/**
* Compute the current session-level warnings. Recomputes the AGENTS.md size
* warning on demand (preferring the main agent's cached value when the agent

View file

@ -14,6 +14,7 @@ export type SessionStatus = 'running' | 'idle' | 'awaiting_approval' | 'awaiting
export interface ISessionActivity {
readonly _serviceBrand: undefined;
status(): SessionStatus;
isIdle(): boolean;
}

View file

@ -14,11 +14,11 @@ import type { ScopeSeed } from '#/_base/di/scope';
export interface ISessionContext {
readonly _serviceBrand: undefined;
readonly sessionId: string;
readonly workspaceId: string;
readonly sessionDir: string;
readonly metaScope: string;
/**
* Persistence scope rooted at this session. `scope()` returns the session
* scope itself; `scope(subKey)` returns `${sessionScope}/${subKey}`. The

View file

@ -63,6 +63,7 @@ export interface SessionMetadataChangedEvent {
export interface ISessionMetadata {
readonly _serviceBrand: undefined;
readonly ready: Promise<void>;
readonly onDidChangeMetadata: Event<SessionMetadataChangedEvent>;
read(): Promise<SessionMeta>;

View file

@ -15,11 +15,8 @@ export interface ISessionSkillCatalog {
readonly _serviceBrand: undefined;
readonly catalog: SkillCatalog;
readonly ready: Promise<void>;
load(): Promise<void>;
reload(): Promise<void>;
}

View file

@ -54,6 +54,7 @@ export interface SessionSwarmRunResult<T = unknown> {
export interface ISessionSwarmService {
readonly _serviceBrand: undefined;
run<T>(args: SessionSwarmRunArgs<T>): Promise<readonly SessionSwarmRunResult<T>[]>;
cancel(args: { readonly callerAgentId: string }): void;
}

View file

@ -46,25 +46,17 @@ export interface ISessionTerminalService {
readonly _serviceBrand: undefined;
create(input: CreateTerminalRequest): Promise<Terminal>;
list(): Promise<readonly Terminal[]>;
get(terminalId: string): Promise<Terminal>;
attach(
terminalId: string,
sink: TerminalAttachSink,
options?: TerminalAttachOptions,
): Promise<{ replayed: number }>;
detach(terminalId: string, sinkId: string): void;
detachAllForSink(sinkId: string): void;
write(terminalId: string, data: string): Promise<void>;
resize(terminalId: string, cols: number, rows: number): Promise<void>;
close(terminalId: string): Promise<{ closed: true }>;
}

View file

@ -16,7 +16,6 @@ export interface ISessionWorkspaceContext {
readonly workDir: string;
readonly additionalDirs: readonly string[];
setWorkDir(workDir: string): void;
resolve(rel: string): string;
isWithin(absPath: string): boolean;

View file

@ -118,7 +118,7 @@ describe('RestGateway', () => {
it('aborts the active turn signal on cancel', async () => {
const gw = ix.get(IRestGateway);
const turn = turnService.launch({ kind: 'user' });
const turn = turnService.launch();
await gw.cancel('s1', 'main', 'bye');
expect(turn.abortController.signal.aborted).toBe(true);

View file

@ -567,7 +567,7 @@ describe('AgentGoalService core workflow hooks', () => {
status: 'active',
turnsUsed: 1,
});
expect(turnService.launches).toEqual([{ kind: 'system_trigger', name: 'goal_continuation' }]);
expect(turnService.launches).toHaveLength(1);
expect(context.get().at(-1)?.origin).toEqual({
kind: 'system_trigger',
name: 'goal_continuation',
@ -596,7 +596,7 @@ describe('AgentGoalService core workflow hooks', () => {
await goals.createGoal({ objective: 'finish the task' });
await goals.setBudgetLimits({ budgetLimits: { tokenBudget: 7 } }, 'model');
const turn = turnService.launch({ kind: 'user' });
const turn = turnService.launch();
await turnService.hooks.onLaunched.run({ turn });
expect(
@ -653,7 +653,7 @@ describe('AgentGoalService core workflow hooks', () => {
status: 'active',
turnsUsed: 0,
});
expect(turnService.launches).toEqual([{ kind: 'system_trigger', name: 'goal_continuation' }]);
expect(turnService.launches).toHaveLength(1);
});
it('requests one final outcome turn after model completion', async () => {

View file

@ -138,7 +138,7 @@ describe('Agent loop', () => {
const controller = new AbortController();
controller.abort(new Error('stop'));
const result = await loop.runTurn(0, { signal: controller.signal });
const result = await loop.run({ turnId: 0, signal: controller.signal });
expect(result.reason).toBe('cancelled');
expect(called).toBe(false);
@ -150,7 +150,7 @@ describe('Agent loop', () => {
throw recoveryError;
});
const result = await loop.runTurn(0);
const result = await loop.run({ turnId: 0 });
expect(result.reason).toBe('failed');
if (result.reason === 'failed') {

View file

@ -87,7 +87,7 @@ describe('AgentPromptService', () => {
it('runs submit hooks before queuing active steers', async () => {
const { context, loop, prompt, turn } = createHarness({ hasActiveTurn: true });
const activeTurn = turn.launch({ kind: 'user' });
const activeTurn = turn.launch();
const seen: Array<Pick<PromptSubmitContext, 'isSteer'> & {
readonly originKind: string | undefined;
}> = [];
@ -125,7 +125,7 @@ describe('AgentPromptService', () => {
it('does not queue active steers blocked by hooks', async () => {
const { context, loop, prompt, turn } = createHarness({ hasActiveTurn: true });
const activeTurn = turn.launch({ kind: 'user' });
const activeTurn = turn.launch();
prompt.hooks.onWillSubmitPrompt.register('block', async (ctx) => {
ctx.block = true;

View file

@ -219,7 +219,7 @@ describe('task notification → main agent (real Agent instance)', () => {
it('RACE: bg completion fires AFTER LLM returns but BEFORE activeTurn is cleared', async () => {
// We're hunting a window: shouldContinueAfterStop reads an empty
// steerBuffer → returns { continue: false } → runTurn unwinds →
// steerBuffer → returns { continue: false } → loop.run unwinds →
// finally block hasn't yet set activeTurn = null. If a steer()
// lands in this window, it gets buffered, then activeTurn=null
// and the buffer is never flushed until the next user prompt.

View file

@ -5,7 +5,6 @@
* production tree. Import from a relative path (`./stubs` or `../turn/stubs`).
*/
import type { PromptOrigin } from '#/agent/contextMemory';
import type { IAgentLoopService } from '#/agent/loop';
import type { IAgentToolExecutorService } from '#/agent/toolExecutor';
import type { IAgentTurnService, Turn } from '#/agent/turn';
@ -20,7 +19,7 @@ export interface StubTurnOptions {
}
/**
* An `IAgentTurnService` stub that also records `launch` origins and exposes the
* An `IAgentTurnService` stub that also records `launch` calls and exposes the
* active turn. `prompts` / `steered` are retained as empty arrays for legacy
* assertions the HEAD `IAgentTurnService` has no `prompt` / `steer` methods, so
* tests that used to drive them should be rewritten against `launch` + hooks.
@ -28,7 +27,7 @@ export interface StubTurnOptions {
export type StubTurn = IAgentTurnService & {
readonly prompts: readonly string[];
readonly steered: readonly string[];
readonly launches: readonly PromptOrigin[];
readonly launches: readonly number[];
};
function makeTurn(id: number): Turn {
@ -57,16 +56,17 @@ function makeAgentLoopHookSlots(): IAgentLoopService['hooks'] {
/** A configurable `IAgentTurnService` stub backed by real `OrderedHookSlot`s. */
export function stubTurn(options: StubTurnOptions = {}): StubTurn {
const launches: PromptOrigin[] = [];
const launches: number[] = [];
let activeTurn: Turn | undefined;
let nextId = typeof options.currentId === 'number' ? options.currentId : 0;
return {
_serviceBrand: undefined,
hooks: makeHooks(),
launch(origin) {
launches.push(origin);
activeTurn = makeTurn(nextId++);
return activeTurn;
launch() {
const turn = makeTurn(nextId++);
launches.push(turn.id);
activeTurn = turn;
return turn;
},
getActiveTurn() {
return options.hasActiveTurn ? activeTurn : undefined;
@ -99,8 +99,8 @@ export function stubLoopWithHooks(): IAgentLoopService {
return {
_serviceBrand: undefined,
hooks,
runTurn: async (_turnId, options) => {
options?.onStepStarted?.(1);
run: async (options) => {
options.onStarted?.(1);
return { reason: 'completed', steps: 0 };
},
};

View file

@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import type { PromptOrigin } from '#/agent/contextMemory';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentContextOpsService, AgentContextOpsService } from '#/agent/contextOps';
import { AgentLoopService, IAgentLoopService } from '#/agent/loop';
@ -22,8 +21,6 @@ import { stubLog } from '../log/stubs';
import { recordingTelemetry } from '../telemetry/stubs';
import { stubLoopWithHooks, stubToolExecutor } from './stubs';
const SYSTEM_ORIGIN: PromptOrigin = { kind: 'system_trigger', name: 'test' };
describe('AgentTurnService ready', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
@ -65,19 +62,19 @@ describe('AgentTurnService ready', () => {
events.push('after');
beforeStepDone.resolve();
});
loop.runTurn = async (turnId, options) => {
loop.run = async (options) => {
events.push('loop');
const signal = options?.signal ?? new AbortController().signal;
const { turnId, signal = new AbortController().signal } = options;
await loop.hooks.beforeStep.run({ turnId, step: 1, signal });
await responseEvent;
events.push('response');
options?.onStepStarted?.(1);
options.onStarted?.(1);
await release;
events.push('done');
return { reason: 'completed', steps: 1 };
};
const turn = ix.get(IAgentTurnService).launch(SYSTEM_ORIGIN);
const turn = ix.get(IAgentTurnService).launch();
void turn.ready.then(
() => {
readySettled = true;
@ -103,9 +100,9 @@ describe('AgentTurnService ready', () => {
it('rejects with an Error when the turn ends before the first step starts', async () => {
const cause = new Error('loop failed before first step');
loop.runTurn = async () => ({ reason: 'failed', error: cause, steps: 0 });
loop.run = async () => ({ reason: 'failed', error: cause, steps: 0 });
const turn = ix.get(IAgentTurnService).launch(SYSTEM_ORIGIN);
const turn = ix.get(IAgentTurnService).launch();
let readyError: unknown;
await turn.ready.catch((error: unknown) => {
readyError = error;
@ -119,16 +116,16 @@ describe('AgentTurnService ready', () => {
it('throws a KimiError when launching while a turn is active', async () => {
const release = createControlledPromise<void>();
loop.runTurn = async () => {
loop.run = async () => {
await release;
return { reason: 'completed', steps: 1 };
};
const turnService = ix.get(IAgentTurnService);
const turn = turnService.launch(SYSTEM_ORIGIN);
const turn = turnService.launch();
let error: unknown;
try {
turnService.launch(SYSTEM_ORIGIN);
turnService.launch();
} catch (caught) {
error = caught;
} finally {
@ -144,7 +141,7 @@ describe('AgentTurnService ready', () => {
});
});
describe('AgentLoopService onStepStarted', () => {
describe('AgentLoopService onStarted', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
@ -195,8 +192,9 @@ describe('AgentLoopService onStepStarted', () => {
},
});
const result = ix.get(IAgentLoopService).runTurn(1, {
onStepStarted: (step) => {
const result = ix.get(IAgentLoopService).run({
turnId: 1,
onStarted: (step) => {
expect(step).toBe(1);
started = true;
stepStarted.resolve();
@ -238,8 +236,9 @@ describe('AgentLoopService onStepStarted', () => {
},
});
const result = ix.get(IAgentLoopService).runTurn(1, {
onStepStarted: (step) => {
const result = ix.get(IAgentLoopService).run({
turnId: 1,
onStarted: (step) => {
expect(step).toBe(1);
started = true;
stepStarted.resolve();
@ -268,8 +267,9 @@ describe('AgentLoopService onStepStarted', () => {
});
await expect(
ix.get(IAgentLoopService).runTurn(1, {
onStepStarted: () => {
ix.get(IAgentLoopService).run({
turnId: 1,
onStarted: () => {
started = true;
},
}),

View file

@ -469,7 +469,6 @@ export interface WarningEvent {
export interface TurnStartedEvent {
readonly type: 'turn.started';
readonly turnId: number;
readonly origin: PromptOrigin;
}
export interface TurnEndedEvent {

View file

@ -16,15 +16,22 @@
import {
IAgentContextMemoryService,
IAgentLifecycleService,
IAgentPromptLegacyService,
ISessionInteractionService,
ISessionContext,
ISessionLifecycleService,
ISessionMetadata,
IWorkspaceRegistry,
toProtocolMessage,
type IAgentScopeHandle,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import { ErrorCode, sessionSnapshotResponseSchema, type Message } from '@moonshot-ai/protocol';
import {
ErrorCode,
sessionSnapshotResponseSchema,
type InFlightTurn,
type Message,
} from '@moonshot-ai/protocol';
import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
@ -111,6 +118,14 @@ export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRou
const offset = history.length - page.length;
items = page.map((msg, i) => toProtocolMessage(session_id, offset + i, msg, meta.createdAt));
}
const currentPromptId =
snapState.inFlightTurn === null
? undefined
: readCurrentPromptId(main);
const inFlightTurn = attachCurrentPromptIdToInFlight(
snapState.inFlightTurn,
currentPromptId,
);
// Pending approvals / questions.
const interaction = handle.accessor.get(ISessionInteractionService);
@ -128,7 +143,7 @@ export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRou
epoch: snapState.epoch,
session,
messages: { items, has_more: hasMore },
in_flight_turn: snapState.inFlightTurn,
in_flight_turn: inFlightTurn,
pending_approvals: pendingApprovals,
pending_questions: pendingQuestions,
},
@ -139,3 +154,21 @@ export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRou
);
app.get(route.path, route.options, route.handler as Parameters<SnapshotRouteHost['get']>[2]);
}
function readCurrentPromptId(main: IAgentScopeHandle | undefined): string | undefined {
if (main === undefined) return undefined;
try {
return main.accessor.get(IAgentPromptLegacyService).list().active?.prompt_id;
} catch {
// Auxiliary reconnect metadata must not make the whole snapshot fail.
return undefined;
}
}
function attachCurrentPromptIdToInFlight(
inFlightTurn: InFlightTurn | null,
currentPromptId: string | undefined,
): InFlightTurn | null {
if (inFlightTurn === null || currentPromptId === undefined) return inFlightTurn;
return { ...inFlightTurn, current_prompt_id: currentPromptId };
}

View file

@ -11,15 +11,121 @@ import {
IAgentContextMemoryService,
IAgentEventSinkService,
IAgentLifecycleService,
IAgentPromptLegacyService,
ISessionInteractionService,
ISessionContext,
ISessionLifecycleService,
ISessionMetadata,
IWorkspaceRegistry,
} from '@moonshot-ai/agent-core-v2';
import { sessionSnapshotResponseSchema, type AgentEvent } from '@moonshot-ai/protocol';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { registerSnapshotRoutes } from '../src/routes/snapshot';
import { type RunningServer, startServer } from '../src/start';
import { authHeaders } from './helpers/auth';
function fakeAccessor(entries: ReadonlyArray<readonly [unknown, unknown]>) {
const services = new Map<unknown, unknown>(entries);
return {
get<T>(id: unknown): T {
if (!services.has(id)) {
throw new Error(`unexpected service request: ${String(id)}`);
}
return services.get(id) as T;
},
};
}
describe('server-v2 snapshot route enrichment', () => {
it('attaches current_prompt_id to an in-flight turn from promptLegacy active state', async () => {
const sessionId = 'sess_snapshot';
const promptId = 'msg_snapshot_prompt';
const workspaceId = 'wd_snapshot_012345abcdef';
const now = Date.parse('2026-01-01T00:00:00.000Z');
const main = {
accessor: fakeAccessor([
[IAgentContextMemoryService, { get: () => [] }],
[
IAgentPromptLegacyService,
{ list: () => ({ active: { prompt_id: promptId }, queued: [] }) },
],
]),
};
const session = {
accessor: fakeAccessor([
[ISessionContext, { workspaceId }],
[
ISessionMetadata,
{
read: async () => ({
id: sessionId,
title: 'Snapshot',
createdAt: now,
updatedAt: now,
archived: false,
}),
},
],
[IAgentLifecycleService, { getHandle: () => main }],
[ISessionInteractionService, { listPending: () => [] }],
]),
};
const core = {
accessor: fakeAccessor([
[ISessionLifecycleService, { resume: async () => session }],
[IWorkspaceRegistry, { get: async () => ({ root: '/workspace' }) }],
]),
};
const broadcaster = {
getSnapshotState: async () => ({
seq: 1,
epoch: 'ep_snapshot',
inFlightTurn: {
turn_id: 7,
assistant_text: 'Hello',
thinking_text: '',
running_tools: [],
},
}),
};
let routeHandler:
| ((
req: { id: string; params: { session_id: string } },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void)
| undefined;
registerSnapshotRoutes(
{
get: (_path, _options, handler) => {
routeHandler = handler;
},
},
{ core: core as never, broadcaster: broadcaster as never },
);
let payload: unknown;
await routeHandler?.(
{ id: 'req_snapshot', params: { session_id: sessionId } },
{
send: (value) => {
payload = value;
},
},
);
const body = payload as { code: number; data: unknown };
expect(body.code).toBe(0);
const snap = sessionSnapshotResponseSchema.parse(body.data);
expect(snap.in_flight_turn).toMatchObject({
turn_id: 7,
assistant_text: 'Hello',
current_prompt_id: promptId,
});
});
});
describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => {
let server: RunningServer | undefined;
let home: string | undefined;