mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-30 19:45:39 +00:00
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* feat(agent-core-v2): insert Workspace lifecycle scope and remove mutable cwd paths
- Insert LifecycleScope.Workspace between App and Session
- Delete session/workspaceCommand domain (addAdditionalDir) and node-sdk RPC
- Remove profile cwd mutation; cwd is fixed at creation
- Make ISessionWorkspaceContext read-only; seed additionalDirs at creation
- Remove TUI and vscode /add-dir commands (to return workspace-scoped)
* feat(agent-core-v2): add Workspace scope with handler-owned session lifecycle
- Add IWorkspaceLifecycleService (App scope): handler registry,
create-or-get handlerFor with inflight join
- Add workspace/workspaceContext seed and workspace/workspaceHandler
(session create/resume/fork as handler child scopes)
- Delete App-level ISessionLifecycleService; callers compose
index -> handlerFor -> handler via sessionLookup helpers
- Slim IBootstrapService; persistence addressing via handler chain
(disk layout byte-identical)
- kap-server routes rewire internally; /api/v1 wire unchanged,
debug surface gains workspace addressing
- Pin red line in domain lint: session/agent must not import
workspace domains
* feat(agent-core-v2): collect workspace resources into the handler scope
- Add Workspace-scope catalogs for skills and agent profiles,
instructions service, and a shared MCP connection manager
(built at materialization, refreshed by watch/plugin events)
- Session catalogs keep their APIs but read seeded snapshots and
refresh via change events; ISessionMcpService removed
- Session create options carry no mcpServers; MCP sources are
config file (wins on name conflicts) and plugins only
- Agent profile/mcp consume the seeded providers
* feat(agent-core-v2): restore add-dir as a workspace-level capability
- Add workspace/workspaceDirs: shared additional-dir set with
addDir({path, persist}); persist=true writes .kimi-code/local.toml,
local.toml watch drives cross-process refresh
- ISessionWorkspaceContext becomes a live read view fed by the
ISessionWorkspaceInfo seed contract and change events
- Restore Session.addAdditionalDir in kimi-code-sdk 1:1, mapping to
the workspace service; restore TUI/vscode /add-dir verbatim
* feat(agent-core-v2): collect os-level services into the workspace scope
- Move fs service, fs watch (shared subscription fan-out), process
runner, and a git facade to Workspace scope; sessionFs domain removed
- Add IWorkspaceToolPolicy with workspace veto wired through tool
activation, execution guard, composed evaluation, and profile
prompt projection; injected via ISessionToolPolicyGate seed
- kap-server fs routes and fs.watch bridge remap to the workspace
services; wire unchanged
* refactor(agent-core-v2): clean up workspace-domain leftovers and docs
- Drop dead code: v2 mergeCallerMcpServers, the transitional
ISessionContext.additionalDirs field, an unreachable guard
- Fix stale domain references in comments; correct test names
- Give the fs-watch refresh test a realistic wait budget under load
- Document the four-scope model and workspace domain in AGENTS.md,
agent-core-v2 docs, and the agent-core-dev skill
* test(node-sdk): wait for the initial MCP connect to settle in the parity list test
v1 connects in the background after create resolves while v2 awaits it
inside create, so an immediate list can catch either side still pending
under CI load
* refactor(agent-core-v2): extract git work-tree discovery into the git domain
- add the pure findGitWorkTree probe in app/git/workTree and expose it
as IGitService.findWorkTree
- switch the git permission policies off the local
findLocalGitWorkTreeMarker helper to the DI service
- reuse findGitWorkTree for AGENTS.md project-root discovery in
agent/profile/context.ts
- add findWorkTree coverage to gitService.test.ts
* feat(kimi-inspect): add Workspace Services view
- add WorkspaceServicesView rail view with a workspace picker on top;
proxies resolve workspace-scope Services on the /workspace/:id route
- extend ChannelScope, ServiceTarget, and ServicePanelDef scope with
'workspace', routed via client.workspace(id).service
- wire the new view into NavRail and App
* refactor(agent-core-v2): extract mcpCore and workspaceMcpConfig domains
- move the scope-agnostic MCP connection layer (stdio/http/sse clients,
connection manager, oauth, config schema, tool naming) from agent/mcp
to the new mcpCore domain
- move the [mcp] config section to app/mcpConfig and OAuth credential
persistence to app/mcpConfig/oauthStore
- introduce the workspace/workspaceMcpConfig domain owning the effective
MCP server set (mcp.json files + plugin contributions, refreshed by
fs watch); workspaceMcp keeps pure connection orchestration
- update the plugin domain, session MCP handle, klient/node-sdk
contracts, and tests accordingly
* refactor(agent-core-v2): remove the fault-injection experimental feature
- delete the faultInjection domain (flag definition, IFaultInjectionService
contract, FaultInjectionService implementation)
- drop the requester-side take() injection point and the constructor
dependency from llmRequester
- remove the flag-gated test cases and the IFlagService stub they needed
- regenerate the state manifest without the faultInjection state keys
* feat(agent-core-v2): gate project-level MCP config behind workspace trust
Add the Workspace-scope IWorkspaceTrust service: an explicit, per-workspace
trust marker persisted under the home (IAtomicDocumentStore, keyed by
encodeWorkDirKey(root)) so a checked-out tree cannot pre-trust itself.
While a workspace is untrusted, workspaceMcpConfig skips the project-level
.mcp.json and .kimi-code/mcp.json files (user-level config and plugin
contributions still load); a trust flip reuses the reload path, so project
servers connect on trust and disconnect on untrust.
Expose the state over kap-server REST: GET /workspaces/{id}/trust,
POST /workspaces/{id}/trust, POST /workspaces/{id}/untrust.
* feat(kimi-inspect): replace the workspace picker with a directory browser
The Workspace Services view now keeps a server-side directory browser in a
left sidebar (over IHostFolderBrowser) instead of a <select> of registered
workspaces. Entries that are registered workspaces carry a workspace badge
plus their IWorkspaceTrust trust state; selecting an unregistered folder
registers it on demand via IWorkspaceService.createOrTouch.
* fix(agent-core-v2): resolve the effective cwd into the profile binding
A default-bound agent recorded no cwd in its profile.bind payload, and no
caller configures ProfileServiceOptions.cwd, so the profile service's cwd
getter fell through to '' and refreshSystemPrompt() rebuilt the prompt
from the server process's cwd: an AGENTS.md edit dropped the workspace
instructions (or swapped in unrelated ones).
bind() now persists the resolved effective cwd (the input's, or the
session's when the input omits it) into profile.bind — the Model's cwd
stays creation-fixed and is always set. The getter's last resort is the
session's own cwd (the value legacy bindings resolved against) instead of
a bare ''.
* refactor(agent-core-v2): introduce the contribution/registry/catalog extension point for agent profiles
- App-scope IAgentProfileRegistry: any scope can register an
AgentProfileContribution keyed by (sourceId, workspaceKey); dedup per
source id, change events drive catalog re-projection
- workspaceAgentProfileLoader domain owns agent-file discovery end to end
(parse / roots / SYSTEM.md / explicit runtime files) with five
Workspace-scope loaders (workspace / user / plugin / extra / explicit)
tagged with the handler's workspaceId; internals live under internal/
- SessionAgentProfileCatalog projects the registry directly (name dedup,
priority adjudication, builtin override rule, inspect()); the
workspace-catalog + sessionData seed relay is gone
- builtin code contributions register as the 'builtin' entry via
BuiltinAgentProfileLoader; plugin agent roots are provided by the
plugin domain as PluginAgentRoot
- remove cwd from the profile binding chain (BindAgentInput /
ProfileBindingSnapshot / AgentConfigData / ProfileModelState /
profile.bind op) — it is always the session's frozen cwd; legacy
wire.jsonl records replay fine (the schema strips the field)
- share markdown frontmatter parsing via _base/text/frontmatter
* fix(agent-core-v2): reconcile the workspace refactor with main
- restore the branch's klient workspaceId scope extension lost to a
file-level conflict resolution (main had no further changes there)
- stub the plugin system-prompt dependencies main added to the profile
service in the profileOps / skillCatalog tests
- correct PLUGIN_SKILL_SOURCE_ID to the App skillSource domain (Agent
scope must not import the Workspace domain)
- kap-server workspaceLayout test supplies the now-required hostIdentity
- regenerate wire/state/config manifests
* fix(agent-core-v2): export the agent-file parse primitives the v2 print CLI consumes
The internal/ split kept parseAgentFileText / resolveAgentPath off the
package entry, but apps/kimi-code's v2 print runner imports them from
@moonshot-ai/agent-core-v2 for --agent-file. Export the two symbols by
name; everything else under internal/ stays domain-private.
* feat(agent-core-v2): return cwd listing for empty fs:search query
An empty fs:search query used to fail request validation (query had a
minimum length of 1), so @-mention pickers had no starting set right
after typing "@". The workspace fs service now answers an empty query
with the workspace root's top-level entries — directories first,
hidden entries excluded, gitignore and exclude_globs honored — mapped
into the search-hit shape (score 1, empty match positions) and capped
by limit. The mirrored protocol wire schema is relaxed in sync.
* test: cover cron-fired steer context and titled session creation
- agent-core-v2: e2e asserting a cron-fired steer turn carries earlier
tool results (the CronCreate job id) into the provider request
- klient: conformance case creating a titled session through implicit
workspace materialization
1397 lines
54 KiB
TypeScript
1397 lines
54 KiB
TypeScript
/**
|
|
* `GET /api/v1/sessions/{sid}/transcript` — live store reads, interaction
|
|
* frames, turn pagination, cold wire rebuild, and query validation.
|
|
*
|
|
* Boots a real server (`startServer`) on a temp home so the live path runs
|
|
* through the real core binding (event bus → projector → store); the cold
|
|
* path re-boots the server on the same home so the session drops out of
|
|
* memory and the route falls back to the wire-records rebuild.
|
|
*/
|
|
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import {
|
|
IAgentContextMemoryService,
|
|
IAgentLifecycleService,
|
|
IWireService,
|
|
IEventBus,
|
|
ISessionInteractionService,
|
|
ISessionQuestionService,
|
|
closeSessionById,
|
|
getLiveSessionById,
|
|
resumeSessionById,
|
|
IModelCatalog,
|
|
type ContextMessage,
|
|
type DomainEvent,
|
|
type ScopeSeed,
|
|
} from '@moonshot-ai/agent-core-v2';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { type RunningServer, startServer } from '../src/start';
|
|
import { TEST_HOST_IDENTITY } from './helpers/hostIdentity';
|
|
import { authHeaders } from './helpers/auth';
|
|
|
|
interface Envelope<T> {
|
|
code: number;
|
|
msg: string;
|
|
data: T;
|
|
request_id: string;
|
|
details?: { path: string; message: string }[];
|
|
}
|
|
|
|
interface FrameContract {
|
|
kind: string;
|
|
text?: string;
|
|
state?: string;
|
|
toolCallId?: string;
|
|
interactionKind?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface TurnContract {
|
|
kind: 'turn';
|
|
turnId: string;
|
|
state: string;
|
|
origin?: { kind: string };
|
|
prompt?: string;
|
|
steps: { stepId: string; state: string; frames: FrameContract[] }[];
|
|
}
|
|
|
|
interface TranscriptContract {
|
|
agent_id: string;
|
|
items: (TurnContract | { kind: 'marker' | 'taskref' })[];
|
|
has_more: boolean;
|
|
tasks: unknown[];
|
|
interactions: {
|
|
interactionId: string;
|
|
interactionKind?: string;
|
|
toolCallId?: string;
|
|
state: string;
|
|
[key: string]: unknown;
|
|
}[];
|
|
meta: Record<string, unknown>;
|
|
agents: { agentId: string; type?: string }[];
|
|
pending_interactions: string[];
|
|
seq?: number;
|
|
}
|
|
|
|
interface OpsCatchupContract {
|
|
agent_id: string;
|
|
batches: { seq: number; ops: { op: string }[] }[];
|
|
latest_seq: number;
|
|
complete: boolean;
|
|
}
|
|
|
|
interface UserMessagesContract {
|
|
agents: {
|
|
agent_id: string;
|
|
messages: {
|
|
turn_id: string;
|
|
ordinal: number;
|
|
state: string;
|
|
origin: { kind: string };
|
|
prompt: string;
|
|
attachment_ids?: string[];
|
|
started_at?: string;
|
|
}[];
|
|
attachments: { attachmentId: string; mediaType: string; source?: unknown }[];
|
|
}[];
|
|
}
|
|
|
|
interface PlanEntryContract {
|
|
tool_call_id: string;
|
|
turn_id: string;
|
|
source: 'interaction' | 'display' | 'output';
|
|
plan: string;
|
|
path?: string;
|
|
options?: { label: string; description?: string }[];
|
|
review?: { state: string; selected_option?: string; feedback?: string };
|
|
}
|
|
|
|
interface PlanContract {
|
|
agent_id: string;
|
|
plans: PlanEntryContract[];
|
|
}
|
|
|
|
function serverEvent(payload: Record<string, unknown>): DomainEvent {
|
|
return payload as unknown as DomainEvent;
|
|
}
|
|
|
|
describe('server-v2 /api/v1/sessions/{sid}/transcript', () => {
|
|
let server: RunningServer | undefined;
|
|
let home: string | undefined;
|
|
let base: string;
|
|
let seeds: ScopeSeed | undefined;
|
|
|
|
beforeEach(async () => {
|
|
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-transcript-'));
|
|
// Seed a stub IModelCatalog so the agent scope can instantiate if a
|
|
// transitive service needs it; the transcript route itself does not.
|
|
const modelCatalog: IModelCatalog = {
|
|
_serviceBrand: undefined,
|
|
get: () => {
|
|
throw new Error('modelCatalog.get not exercised in this test');
|
|
},
|
|
getRequester: () => {
|
|
throw new Error('modelCatalog.getRequester not exercised in this test');
|
|
},
|
|
inspect: () => {
|
|
throw new Error('modelCatalog.inspect not exercised in this test');
|
|
},
|
|
ping: () => {
|
|
throw new Error('modelCatalog.ping not exercised in this test');
|
|
},
|
|
findByName: () => [],
|
|
listModels: async () => [],
|
|
listProviders: async () => [],
|
|
getProvider: async () => {
|
|
throw new Error('modelCatalog.getProvider not exercised in this test');
|
|
},
|
|
setDefaultModel: async () => {
|
|
throw new Error('modelCatalog.setDefaultModel not exercised in this test');
|
|
},
|
|
};
|
|
seeds = [[IModelCatalog, modelCatalog]];
|
|
await boot();
|
|
});
|
|
|
|
async function boot(): Promise<void> {
|
|
server = await startServer({
|
|
hostIdentity: TEST_HOST_IDENTITY,
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
homeDir: home as string,
|
|
logLevel: 'silent',
|
|
seeds,
|
|
});
|
|
base = `http://127.0.0.1:${server.port}`;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
if (server !== undefined) {
|
|
await server.close();
|
|
server = undefined;
|
|
}
|
|
if (home !== undefined) {
|
|
await rm(home, { recursive: true, force: true });
|
|
home = undefined;
|
|
}
|
|
});
|
|
|
|
async function getJson<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
|
|
const res = await fetch(`${base}${path}`, {
|
|
headers: authHeaders(server as RunningServer),
|
|
} as never);
|
|
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
|
}
|
|
|
|
async function createSession(): Promise<string> {
|
|
const res = await fetch(`${base}/api/v1/sessions`, {
|
|
method: 'POST',
|
|
headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }),
|
|
body: JSON.stringify({ metadata: { cwd: home as string } }),
|
|
} as never);
|
|
const body = (await res.json()) as Envelope<{ id: string }>;
|
|
expect(body.code).toBe(0);
|
|
return body.data.id;
|
|
}
|
|
|
|
/** Ensure the main agent exists (server-v2 does not create it with the session). */
|
|
async function ensureMainAgent(sessionId: string): Promise<void> {
|
|
const session = getLiveSessionById(server!.core.accessor, sessionId);
|
|
if (session === undefined) throw new Error(`session ${sessionId} not found`);
|
|
if (session.accessor.get(IAgentLifecycleService).get('main') === undefined) {
|
|
await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' });
|
|
}
|
|
}
|
|
|
|
function mainAgentBus(sessionId: string): IEventBus {
|
|
const session = getLiveSessionById(server!.core.accessor, sessionId);
|
|
const agent = session!.accessor.get(IAgentLifecycleService).get('main');
|
|
return agent!.accessor.get(IEventBus);
|
|
}
|
|
|
|
async function seedMainAgentMessages(
|
|
sessionId: string,
|
|
messages: readonly ContextMessage[],
|
|
): Promise<void> {
|
|
const session = getLiveSessionById(server!.core.accessor, sessionId);
|
|
const agent = session!.accessor.get(IAgentLifecycleService).get('main');
|
|
agent!.accessor.get(IAgentContextMemoryService).append(...messages);
|
|
await agent!.accessor.get(IWireService).flush();
|
|
}
|
|
|
|
it('streams a live turn tree: deltas flush into full-text frames at step end', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
|
|
// First read binds the transcript (empty).
|
|
const empty = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
expect(empty.body.code).toBe(0);
|
|
expect(empty.body.data.items).toEqual([]);
|
|
expect(empty.body.data.has_more).toBe(false);
|
|
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
|
|
bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 }));
|
|
bus.publish(serverEvent({ type: 'assistant.delta', turnId: 1, delta: 'Hello' }));
|
|
bus.publish(serverEvent({ type: 'assistant.delta', turnId: 1, delta: ' world' }));
|
|
bus.publish(
|
|
serverEvent({
|
|
type: 'tool.call.started',
|
|
turnId: 1,
|
|
toolCallId: 'call_1',
|
|
name: 'Bash',
|
|
args: { command: 'ls' },
|
|
}),
|
|
);
|
|
bus.publish(serverEvent({ type: 'tool.result', turnId: 1, toolCallId: 'call_1', output: 'a.txt' }));
|
|
bus.publish(serverEvent({ type: 'turn.step.completed', turnId: 1, step: 1 }));
|
|
bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
|
|
|
|
const { body } = await getJson<TranscriptContract>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=main`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
const turn = body.data.items.find(
|
|
(item): item is TurnContract => item.kind === 'turn' && item.turnId === 't1',
|
|
);
|
|
expect(turn).toBeDefined();
|
|
expect(turn!.state).toBe('completed');
|
|
expect(turn!.steps).toHaveLength(1);
|
|
const frames = turn!.steps[0]!.frames;
|
|
expect(frames).toContainEqual(
|
|
expect.objectContaining({ kind: 'text', text: 'Hello world' }),
|
|
);
|
|
expect(frames).toContainEqual(
|
|
expect.objectContaining({
|
|
kind: 'tool',
|
|
toolCallId: 'call_1',
|
|
state: 'done',
|
|
output: 'a.txt',
|
|
}),
|
|
);
|
|
// Roster descriptor for the main agent is present.
|
|
await vi.waitFor(async () => {
|
|
const again = await getJson<TranscriptContract>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=main`,
|
|
);
|
|
expect(again.body.data.agents).toContainEqual({ agentId: 'main', type: 'main' });
|
|
});
|
|
});
|
|
|
|
it('surfaces approval interactions as global entities with pending ids', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
// Bind first so the interaction listeners are attached.
|
|
await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
|
|
bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 }));
|
|
bus.publish(
|
|
serverEvent({
|
|
type: 'tool.call.started',
|
|
turnId: 1,
|
|
toolCallId: 'call_9',
|
|
name: 'Bash',
|
|
args: {},
|
|
}),
|
|
);
|
|
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
const interactions = session!.accessor.get(ISessionInteractionService);
|
|
interactions.enqueue({
|
|
id: 'apr-1',
|
|
kind: 'approval',
|
|
payload: {
|
|
toolCallId: 'call_9',
|
|
toolName: 'Bash',
|
|
action: 'run',
|
|
display: { kind: 'command', command: 'ls' },
|
|
},
|
|
origin: { agentId: 'main', turnId: 1 },
|
|
});
|
|
|
|
let { body } = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
expect(body.data.pending_interactions).toEqual(['apr-1']);
|
|
expect(body.data.interactions).toContainEqual(
|
|
expect.objectContaining({
|
|
interactionId: 'apr-1',
|
|
interactionKind: 'approval',
|
|
toolCallId: 'call_9',
|
|
state: 'pending',
|
|
}),
|
|
);
|
|
|
|
interactions.respond('apr-1', { decision: 'approved' });
|
|
({ body } = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`));
|
|
expect(body.data.pending_interactions).toEqual([]);
|
|
expect(body.data.interactions).toContainEqual(
|
|
expect.objectContaining({ interactionId: 'apr-1', state: 'approved' }),
|
|
);
|
|
const frames = (body.data.items[0] as TurnContract).steps[0]!.frames;
|
|
expect(frames).toContainEqual(
|
|
expect.objectContaining({ kind: 'tool', toolCallId: 'call_9', approvalId: 'apr-1' }),
|
|
);
|
|
});
|
|
|
|
it('paginates live turns with page_size and before_turn', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
|
|
const bus = mainAgentBus(id);
|
|
for (const turnId of [1, 2, 3]) {
|
|
bus.publish(serverEvent({ type: 'turn.started', turnId, origin: { kind: 'user' } }));
|
|
bus.publish(serverEvent({ type: 'turn.ended', turnId, reason: 'completed' }));
|
|
}
|
|
|
|
const page = await getJson<TranscriptContract>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=main&page_size=2`,
|
|
);
|
|
expect(page.body.data.items.map((item) => (item as TurnContract).turnId)).toEqual(['t2', 't3']);
|
|
expect(page.body.data.has_more).toBe(true);
|
|
|
|
const older = await getJson<TranscriptContract>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=main&page_size=2&before_turn=t3`,
|
|
);
|
|
expect(older.body.data.items.map((item) => (item as TurnContract).turnId)).toEqual(['t1', 't2']);
|
|
expect(older.body.data.has_more).toBe(false);
|
|
|
|
// Unknown agent id on a live session pages empty instead of 404ing.
|
|
const unknown = await getJson<TranscriptContract>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=nope`,
|
|
);
|
|
expect(unknown.body.code).toBe(0);
|
|
expect(unknown.body.data.items).toEqual([]);
|
|
});
|
|
|
|
it('rebuilds the main agent for a cold session from the wire records', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
await seedMainAgentMessages(id, [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'running' }],
|
|
toolCalls: [{ type: 'function', id: 'call_1', name: 'Bash', arguments: '{"cmd":"ls"}' }],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: [{ type: 'text', text: 'file.txt' }],
|
|
toolCalls: [],
|
|
toolCallId: 'call_1',
|
|
},
|
|
]);
|
|
|
|
// Reboot on the same home — the session drops out of memory.
|
|
await server!.close();
|
|
server = undefined;
|
|
await boot();
|
|
|
|
const { body } = await getJson<TranscriptContract>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=main`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.has_more).toBe(false);
|
|
expect(body.data.agents).toEqual([{ agentId: 'main', type: 'main' }]);
|
|
expect(body.data.pending_interactions).toEqual([]);
|
|
|
|
const turn = body.data.items.find(
|
|
(item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0',
|
|
);
|
|
expect(turn).toBeDefined();
|
|
expect(turn!.state).toBe('completed');
|
|
expect(turn!.prompt).toBe('hi');
|
|
const frames = turn!.steps[0]!.frames;
|
|
expect(frames).toContainEqual(expect.objectContaining({ kind: 'text', text: 'running' }));
|
|
expect(frames).toContainEqual(
|
|
expect.objectContaining({
|
|
kind: 'tool',
|
|
toolCallId: 'call_1',
|
|
state: 'done',
|
|
output: 'file.txt',
|
|
}),
|
|
);
|
|
|
|
// Cold reads of an agent without any records page empty.
|
|
const sub = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=sub-1`);
|
|
expect(sub.body.code).toBe(0);
|
|
expect(sub.body.data.items).toEqual([]);
|
|
expect(sub.body.data.has_more).toBe(false);
|
|
});
|
|
|
|
it('backfills a resumed live session from the wire records, then continues live', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
await seedMainAgentMessages(id, [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'running' }], toolCalls: [] },
|
|
]);
|
|
|
|
// Reboot on the same home, then RESUME the session: it is live again, so
|
|
// the route answers from the live store — which must first backfill the
|
|
// persisted history (0-based ordinals, matching the engine's numbering).
|
|
await server!.close();
|
|
server = undefined;
|
|
await boot();
|
|
await resumeSessionById(server!.core.accessor, id);
|
|
|
|
const { body } = await getJson<TranscriptContract>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=main`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
const turn = body.data.items.find(
|
|
(item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0',
|
|
);
|
|
expect(turn).toBeDefined();
|
|
expect(turn!.state).toBe('completed');
|
|
expect(turn!.prompt).toBe('hi');
|
|
|
|
// A subsequent live turn continues the ordinal sequence without collision.
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
|
|
bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
|
|
const again = await getJson<TranscriptContract>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=main`,
|
|
);
|
|
expect(again.body.data.items.map((item) => (item as TurnContract).turnId)).toEqual(['t0', 't1']);
|
|
});
|
|
|
|
it('rebuilds a subagent for a cold session from its own wire records', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' });
|
|
sub.accessor
|
|
.get(IAgentContextMemoryService)
|
|
.append(
|
|
{ role: 'user', content: [{ type: 'text', text: 'scan the repo' }], toolCalls: [] } as ContextMessage,
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'scanning' }], toolCalls: [] } as ContextMessage,
|
|
);
|
|
await sub.accessor.get(IWireService).flush();
|
|
|
|
// Reboot on the same home — the session drops out of memory.
|
|
await server!.close();
|
|
server = undefined;
|
|
await boot();
|
|
|
|
const { body } = await getJson<TranscriptContract>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=sub-1`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
const turn = body.data.items.find(
|
|
(item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0',
|
|
);
|
|
expect(turn).toBeDefined();
|
|
expect(turn!.prompt).toBe('scan the repo');
|
|
// An agent without any records still pages empty.
|
|
const none = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=nope`);
|
|
expect(none.body.code).toBe(0);
|
|
expect(none.body.data.items).toEqual([]);
|
|
});
|
|
|
|
it('backfills an unmaterialized subagent for a resumed live session', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' });
|
|
sub.accessor
|
|
.get(IAgentContextMemoryService)
|
|
.append(
|
|
{ role: 'user', content: [{ type: 'text', text: 'scan the repo' }], toolCalls: [] } as ContextMessage,
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'scanning' }], toolCalls: [] } as ContextMessage,
|
|
);
|
|
await sub.accessor.get(IWireService).flush();
|
|
|
|
// Reboot + resume: the subagent is not materialized again, but its
|
|
// transcript must come back established from the persisted records, and
|
|
// the roster must list it.
|
|
await server!.close();
|
|
server = undefined;
|
|
await boot();
|
|
await resumeSessionById(server!.core.accessor, id);
|
|
expect(
|
|
getLiveSessionById(server!.core.accessor, id)!
|
|
.accessor.get(IAgentLifecycleService)
|
|
.get('sub-1'),
|
|
).toBeUndefined();
|
|
|
|
const { body } = await getJson<TranscriptContract>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=sub-1`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
const turn = body.data.items.find(
|
|
(item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0',
|
|
);
|
|
expect(turn).toBeDefined();
|
|
expect(turn!.prompt).toBe('scan the repo');
|
|
expect(body.data.agents).toContainEqual(expect.objectContaining({ agentId: 'sub-1' }));
|
|
});
|
|
|
|
it('keeps the metadata-seeded subagent descriptor after an on-demand backfill', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
const sub = await session!.accessor
|
|
.get(IAgentLifecycleService)
|
|
.create({ agentId: 'sub-1', labels: { parentAgentId: 'main' } });
|
|
sub.accessor
|
|
.get(IAgentContextMemoryService)
|
|
.append(
|
|
{ role: 'user', content: [{ type: 'text', text: 'scan the repo' }], toolCalls: [] } as ContextMessage,
|
|
);
|
|
await sub.accessor.get(IWireService).flush();
|
|
|
|
// The roster seeds from session metadata when the transcript binds; the
|
|
// subsequent on-demand backfill for the subagent must not downgrade the
|
|
// descriptor back to `{ agentId, type }`.
|
|
await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
const { body } = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=sub-1`);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.agents).toContainEqual(
|
|
expect.objectContaining({ agentId: 'sub-1', type: 'sub', parentAgentId: 'main' }),
|
|
);
|
|
});
|
|
|
|
it('announces a pre-existing pending approval against the backfilled tool frame', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
// Persist a tool call (assistant message with toolCalls) before anything
|
|
// binds — the persisted frame is the placement/back-link target.
|
|
await seedMainAgentMessages(id, [
|
|
{ role: 'user', content: [{ type: 'text', text: 'run ls' }], toolCalls: [] },
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'running' }],
|
|
toolCalls: [{ type: 'function', id: 'call_9', name: 'Bash', arguments: '{"command":"ls"}' }],
|
|
},
|
|
]);
|
|
|
|
// The approval is already pending when the transcript binds.
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
session!.accessor.get(ISessionInteractionService).enqueue({
|
|
id: 'apr-1',
|
|
kind: 'approval',
|
|
payload: { toolCallId: 'call_9', toolName: 'Bash', action: 'run' },
|
|
origin: { agentId: 'main', turnId: 0 },
|
|
});
|
|
|
|
// Binding defers the announce until after the backfill, so the entity
|
|
// anchors at the backfilled tool call and resolve can back-link it.
|
|
const { body } = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
expect(body.data.pending_interactions).toEqual(['apr-1']);
|
|
expect(body.data.interactions).toContainEqual(
|
|
expect.objectContaining({
|
|
interactionId: 'apr-1',
|
|
interactionKind: 'approval',
|
|
toolCallId: 'call_9',
|
|
state: 'pending',
|
|
}),
|
|
);
|
|
|
|
session!.accessor.get(ISessionInteractionService).respond('apr-1', { decision: 'approved' });
|
|
const after = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
const turnAfter = after.body.data.items.find(
|
|
(item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0',
|
|
);
|
|
expect(turnAfter!.steps.flatMap((step) => step.frames)).toContainEqual(
|
|
expect.objectContaining({ kind: 'tool', toolCallId: 'call_9', approvalId: 'apr-1' }),
|
|
);
|
|
});
|
|
|
|
it('does not roster a ghost agent for an unknown agent id on a live session', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
|
|
// Probing a nonexistent agent pages empty (no wire records)…
|
|
const none = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=nope`);
|
|
expect(none.body.code).toBe(0);
|
|
expect(none.body.data.items).toEqual([]);
|
|
|
|
// …but must not conjure a ghost roster entry.
|
|
const main = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
expect(main.body.data.agents.map((a) => a.agentId)).not.toContain('nope');
|
|
});
|
|
|
|
it('seeds a subagent pending question only after its own backfill', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' });
|
|
sub.accessor
|
|
.get(IAgentContextMemoryService)
|
|
.append(
|
|
{ role: 'user', content: [{ type: 'text', text: 'scan' }], toolCalls: [] } as ContextMessage,
|
|
{
|
|
role: 'assistant',
|
|
content: [],
|
|
toolCalls: [{ type: 'function', id: 'call_q', name: 'AskUserQuestion', arguments: '{}' }],
|
|
} as ContextMessage,
|
|
);
|
|
await sub.accessor.get(IWireService).flush();
|
|
|
|
// The subagent's question is pending BEFORE the transcript binds.
|
|
const questions = session!.accessor.get(ISessionQuestionService);
|
|
const pending = questions.request(
|
|
{
|
|
turnId: 0,
|
|
toolCallId: 'call_q',
|
|
questions: [{ question: 'Pick?', options: [{ label: 'A' }] }],
|
|
},
|
|
{ agentId: 'sub-1' },
|
|
);
|
|
|
|
// Binding seeds only main-owned pendings after the main backfill — the
|
|
// subagent's question waits for its own history.
|
|
const mainBody = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
expect(mainBody.body.data.pending_interactions).toEqual([]);
|
|
|
|
const subBody = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=sub-1`);
|
|
expect(subBody.body.data.pending_interactions).toEqual(['call_q']);
|
|
expect(subBody.body.data.interactions).toContainEqual(
|
|
expect.objectContaining({
|
|
interactionId: 'call_q',
|
|
interactionKind: 'question',
|
|
toolCallId: 'call_q',
|
|
state: 'pending',
|
|
}),
|
|
);
|
|
|
|
questions.dismiss('call_q');
|
|
await pending;
|
|
});
|
|
|
|
it('does not fabricate a roster entry for an unknown agent on a cold session', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
await seedMainAgentMessages(id, [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] },
|
|
]);
|
|
|
|
// Reboot on the same home — the session drops out of memory (cold path).
|
|
await server!.close();
|
|
server = undefined;
|
|
await boot();
|
|
|
|
const none = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=nope`);
|
|
expect(none.body.code).toBe(0);
|
|
expect(none.body.data.items).toEqual([]);
|
|
// No ghost entry for the probe — and the roster still comes from the
|
|
// persisted session metadata.
|
|
expect(none.body.data.agents.map((a) => a.agentId)).not.toContain('nope');
|
|
expect(none.body.data.agents).toContainEqual({ agentId: 'main', type: 'main' });
|
|
});
|
|
|
|
it('returns 40401 for an unknown session', async () => {
|
|
const { body } = await getJson<null>('/api/v1/sessions/nope/transcript?agent_id=main');
|
|
expect(body.code).toBe(40401);
|
|
});
|
|
|
|
it('drops the live store when the session closes so reads fall back to the cold rebuild', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
await seedMainAgentMessages(id, [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'hello there' }], toolCalls: [] },
|
|
]);
|
|
|
|
// Bind the live store; the backfill serves the persisted turn.
|
|
const bound = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
expect(bound.body.data.items).toHaveLength(1);
|
|
|
|
// A live-only turn (never persisted) distinguishes the stale store from
|
|
// the cold rebuild: served live it would show up, from disk it cannot.
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
|
|
bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
|
|
|
|
await closeSessionById(server!.core.accessor, id);
|
|
|
|
const { body } = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.items.map((item) => (item as TurnContract).turnId)).toEqual(['t0']);
|
|
const turn = body.data.items[0] as TurnContract;
|
|
expect(turn.prompt).toBe('hi');
|
|
expect(turn.steps[0]!.frames).toContainEqual(
|
|
expect.objectContaining({ kind: 'text', text: 'hello there' }),
|
|
);
|
|
});
|
|
|
|
it('heals the missing stream prefix after a mid-turn attach once the turn ends', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
|
|
// A turn is already streaming, but none of the step content is persisted
|
|
// yet (the engine flushes response content when the request completes).
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(
|
|
serverEvent({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'hi' }),
|
|
);
|
|
bus.publish(serverEvent({ type: 'turn.step.started', turnId: 0, step: 1 }));
|
|
bus.publish(serverEvent({ type: 'assistant.delta', turnId: 0, delta: 'Hello ' }));
|
|
await seedMainAgentMessages(id, [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
]);
|
|
|
|
// The transcript attaches now: the backfill sees only the user message
|
|
// and the projector missed the early deltas, so the live frame ends up
|
|
// suffix-only.
|
|
await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
bus.publish(serverEvent({ type: 'assistant.delta', turnId: 0, delta: 'world' }));
|
|
const suffix = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
const suffixTurn = suffix.body.data.items.find(
|
|
(item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0',
|
|
);
|
|
expect(suffixTurn!.steps[0]!.frames).toContainEqual(
|
|
expect.objectContaining({ kind: 'text', text: 'world' }),
|
|
);
|
|
|
|
// The request completes: the full text lands on disk, then the turn ends.
|
|
await seedMainAgentMessages(id, [
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'Hello world' }], toolCalls: [] },
|
|
]);
|
|
bus.publish(serverEvent({ type: 'turn.step.completed', turnId: 0, step: 1 }));
|
|
bus.publish(serverEvent({ type: 'turn.ended', turnId: 0, reason: 'completed' }));
|
|
|
|
// The debounced post-turn heal re-reads the persisted turn and merges it
|
|
// back: the prefix is restored and the header recovers origin/prompt.
|
|
await vi.waitFor(
|
|
async () => {
|
|
const { body } = await getJson<TranscriptContract>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=main`,
|
|
);
|
|
const turn = body.data.items.find(
|
|
(item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0',
|
|
);
|
|
expect(turn).toBeDefined();
|
|
expect(turn!.origin).toMatchObject({ kind: 'user' });
|
|
expect(turn!.prompt).toBe('hi');
|
|
expect(turn!.steps[0]!.frames).toContainEqual(
|
|
expect.objectContaining({ kind: 'text', text: 'Hello world' }),
|
|
);
|
|
},
|
|
{ timeout: 5000, interval: 50 },
|
|
);
|
|
});
|
|
|
|
it('routes a subagent question to the subagent transcript, not main', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' });
|
|
|
|
// Bind the transcript (main + any agent appearing later).
|
|
await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
|
|
const subBus = sub.accessor.get(IEventBus);
|
|
subBus.publish(
|
|
serverEvent({ type: 'turn.started', turnId: 0, origin: { kind: 'task', taskId: 'task-1' } }),
|
|
);
|
|
subBus.publish(serverEvent({ type: 'turn.step.started', turnId: 0, step: 1 }));
|
|
subBus.publish(
|
|
serverEvent({
|
|
type: 'tool.call.started',
|
|
turnId: 0,
|
|
toolCallId: 'call_q',
|
|
name: 'AskUserQuestion',
|
|
args: {},
|
|
}),
|
|
);
|
|
|
|
// The question carries its owning agent on the interaction origin (see
|
|
// ISessionQuestionService.request's agentId option).
|
|
const questions = session!.accessor.get(ISessionQuestionService);
|
|
const pending = questions.request(
|
|
{
|
|
turnId: 0,
|
|
toolCallId: 'call_q',
|
|
questions: [{ question: 'Pick one?', options: [{ label: 'A' }, { label: 'B' }] }],
|
|
},
|
|
{ agentId: 'sub-1' },
|
|
);
|
|
|
|
const subBody = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=sub-1`);
|
|
expect(subBody.body.data.pending_interactions).toEqual(['call_q']);
|
|
expect(subBody.body.data.interactions).toContainEqual(
|
|
expect.objectContaining({
|
|
interactionId: 'call_q',
|
|
interactionKind: 'question',
|
|
toolCallId: 'call_q',
|
|
state: 'pending',
|
|
}),
|
|
);
|
|
|
|
const mainBody = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
expect(mainBody.body.data.pending_interactions).toEqual([]);
|
|
|
|
questions.dismiss('call_q');
|
|
await pending;
|
|
});
|
|
|
|
it('rejects path-hostile agent ids with 40001', async () => {
|
|
const id = await createSession();
|
|
const { body } = await getJson<null>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=${encodeURIComponent('../main')}`,
|
|
);
|
|
expect(body.code).toBe(40001);
|
|
});
|
|
|
|
it('rejects before_turn + after_turn together with 40001', async () => {
|
|
const id = await createSession();
|
|
const { body } = await getJson<null>(
|
|
`/api/v1/sessions/${id}/transcript?agent_id=main&before_turn=t2&after_turn=t1`,
|
|
);
|
|
expect(body.code).toBe(40001);
|
|
});
|
|
|
|
it('carries the op-batch watermark on the live transcript response', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
|
|
const bound = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
expect(bound.body.data.seq).toBeTypeOf('number');
|
|
const base = bound.body.data.seq!;
|
|
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
|
|
bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
|
|
|
|
const after = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
expect(after.body.data.seq).toBeGreaterThan(base);
|
|
});
|
|
|
|
it('serves catch-up batches with seq > since_seq on the ops route', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
|
|
// Bind the transcript and read the baseline watermark.
|
|
const bound = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
const base = bound.body.data.seq!;
|
|
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
|
|
bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
|
|
|
|
const catchup = await getJson<OpsCatchupContract>(
|
|
`/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=${base}`,
|
|
);
|
|
expect(catchup.body.code).toBe(0);
|
|
expect(catchup.body.data.complete).toBe(true);
|
|
expect(catchup.body.data.latest_seq).toBeGreaterThan(base);
|
|
const seqs = catchup.body.data.batches.map((batch) => batch.seq);
|
|
// Ascending, consecutive, and strictly past the cursor.
|
|
expect(seqs.every((seq) => seq > base)).toBe(true);
|
|
expect(seqs).toEqual(seqs.map((_, i) => seqs[0]! + i));
|
|
expect(
|
|
catchup.body.data.batches.some((batch) => batch.ops.some((op) => op.op === 'turn.upsert')),
|
|
).toBe(true);
|
|
|
|
// An up-to-date cursor replays nothing but is still complete.
|
|
const current = await getJson<OpsCatchupContract>(
|
|
`/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=${catchup.body.data.latest_seq}`,
|
|
);
|
|
expect(current.body.data).toMatchObject({ batches: [], complete: true });
|
|
|
|
// A cursor ahead of the watermark cannot be covered.
|
|
const stale = await getJson<OpsCatchupContract>(
|
|
`/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=99999`,
|
|
);
|
|
expect(stale.body.data.complete).toBe(false);
|
|
});
|
|
|
|
it('answers complete:false for a cold session and 40401 for an unknown one on the ops route', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
await seedMainAgentMessages(id, [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
]);
|
|
|
|
// Reboot on the same home — the session drops out of memory (no journal).
|
|
await server!.close();
|
|
server = undefined;
|
|
await boot();
|
|
|
|
const cold = await getJson<OpsCatchupContract>(
|
|
`/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=0`,
|
|
);
|
|
expect(cold.body.code).toBe(0);
|
|
expect(cold.body.data).toMatchObject({ batches: [], complete: false });
|
|
|
|
const missing = await getJson<null>(
|
|
`/api/v1/sessions/nope/transcript/ops?agent_id=main&since_seq=0`,
|
|
);
|
|
expect(missing.body.code).toBe(40401);
|
|
});
|
|
|
|
it('rejects invalid since_seq / agent_id on the ops route with 40001', async () => {
|
|
const id = await createSession();
|
|
const negative = await getJson<null>(
|
|
`/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=-1`,
|
|
);
|
|
expect(negative.body.code).toBe(40001);
|
|
const hostile = await getJson<null>(
|
|
`/api/v1/sessions/${id}/transcript/ops?agent_id=${encodeURIComponent('../main')}&since_seq=0`,
|
|
);
|
|
expect(hostile.body.code).toBe(40001);
|
|
});
|
|
|
|
it('serves every prompted turn for one agent on the user-messages route (live)', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
// Bind the transcript before publishing live turns.
|
|
await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(
|
|
serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'first' }),
|
|
);
|
|
bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
|
|
// A turn without a prompt (non-displayable origin) is projected out.
|
|
bus.publish(
|
|
serverEvent({ type: 'turn.started', turnId: 2, origin: { kind: 'task', taskId: 'task-1' } }),
|
|
);
|
|
bus.publish(serverEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' }));
|
|
bus.publish(
|
|
serverEvent({ type: 'turn.started', turnId: 3, origin: { kind: 'user' }, prompt: 'second' }),
|
|
);
|
|
bus.publish(serverEvent({ type: 'turn.ended', turnId: 3, reason: 'completed' }));
|
|
|
|
const { body } = await getJson<UserMessagesContract>(
|
|
`/api/v1/sessions/${id}/transcript/user-messages?agent_id=main`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.agents).toHaveLength(1);
|
|
const main = body.data.agents[0]!;
|
|
expect(main.agent_id).toBe('main');
|
|
expect(main.messages.map((m) => [m.turn_id, m.prompt])).toEqual([
|
|
['t1', 'first'],
|
|
['t3', 'second'],
|
|
]);
|
|
expect(main.messages[0]).toMatchObject({ ordinal: 1, state: 'completed' });
|
|
expect(main.messages[0]!.origin).toMatchObject({ kind: 'user' });
|
|
expect(main.attachments).toEqual([]);
|
|
});
|
|
|
|
it('serves per-agent user messages for every rostered agent when agent_id is omitted (live)', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' });
|
|
sub.accessor
|
|
.get(IAgentContextMemoryService)
|
|
.append({ role: 'user', content: [{ type: 'text', text: 'scan the repo' }], toolCalls: [] } as ContextMessage);
|
|
await sub.accessor.get(IWireService).flush();
|
|
|
|
// Bind the transcript BEFORE publishing live turns (an unbound store
|
|
// cannot project bus events, and live-only turns never reach the disk).
|
|
const bound = await getJson<UserMessagesContract>(`/api/v1/sessions/${id}/transcript/user-messages`);
|
|
const boundByAgent = new Map(bound.body.data.agents.map((a) => [a.agent_id, a]));
|
|
expect(boundByAgent.get('main')!.messages).toEqual([]);
|
|
// The agent_id-less read backfills sub-1 on demand: its persisted run
|
|
// prompt is that agent's own user message.
|
|
expect(boundByAgent.get('sub-1')!.messages.map((m) => m.prompt)).toEqual(['scan the repo']);
|
|
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(
|
|
serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'hello main' }),
|
|
);
|
|
bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' }));
|
|
|
|
const { body } = await getJson<UserMessagesContract>(
|
|
`/api/v1/sessions/${id}/transcript/user-messages`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
const byAgent = new Map(body.data.agents.map((a) => [a.agent_id, a]));
|
|
expect(byAgent.get('main')!.messages.map((m) => m.prompt)).toEqual(['hello main']);
|
|
expect(byAgent.get('sub-1')!.messages.map((m) => m.prompt)).toEqual(['scan the repo']);
|
|
});
|
|
|
|
it('rebuilds per-agent user messages for a cold session, folding hidden origins', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
await seedMainAgentMessages(id, [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] },
|
|
// Folded away entirely (mid-turn context, opens no turn).
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'injected reminder' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'injection', variant: 'reminder' },
|
|
} as ContextMessage,
|
|
// Opens a promptless engine turn — projected out by the missing prompt.
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'subagent run prompt' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'system_trigger', name: 'subagent' },
|
|
} as ContextMessage,
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'second question' },
|
|
{ type: 'image', source: { kind: 'url', url: 'https://example.com/a.png' } },
|
|
],
|
|
toolCalls: [],
|
|
} as ContextMessage,
|
|
]);
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
const sub = await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' });
|
|
sub.accessor
|
|
.get(IAgentContextMemoryService)
|
|
.append({ role: 'user', content: [{ type: 'text', text: 'scan the repo' }], toolCalls: [] } as ContextMessage);
|
|
await sub.accessor.get(IWireService).flush();
|
|
|
|
// Reboot on the same home — the session drops out of memory (cold path).
|
|
await server!.close();
|
|
server = undefined;
|
|
await boot();
|
|
|
|
const { body } = await getJson<UserMessagesContract>(
|
|
`/api/v1/sessions/${id}/transcript/user-messages`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
const byAgent = new Map(body.data.agents.map((a) => [a.agent_id, a]));
|
|
|
|
const main = byAgent.get('main')!;
|
|
expect(main.messages.map((m) => [m.turn_id, m.prompt])).toEqual([
|
|
['t0', 'hi'],
|
|
['t2', 'second question'],
|
|
]);
|
|
// The image part became an attachment entity referenced by the turn.
|
|
expect(main.messages[1]!.attachment_ids).toEqual(['att_1']);
|
|
expect(main.attachments).toEqual([
|
|
expect.objectContaining({
|
|
attachmentId: 'att_1',
|
|
mediaType: 'image/*',
|
|
source: { kind: 'url', url: 'https://example.com/a.png' },
|
|
}),
|
|
]);
|
|
|
|
expect(byAgent.get('sub-1')!.messages.map((m) => m.prompt)).toEqual(['scan the repo']);
|
|
|
|
// Narrowed to one agent, only that agent comes back.
|
|
const single = await getJson<UserMessagesContract>(
|
|
`/api/v1/sessions/${id}/transcript/user-messages?agent_id=main`,
|
|
);
|
|
expect(single.body.data.agents.map((a) => a.agent_id)).toEqual(['main']);
|
|
});
|
|
|
|
it('answers 40401 for an unknown session and 40001 for a hostile agent id on the user-messages route', async () => {
|
|
const missing = await getJson<null>('/api/v1/sessions/nope/transcript/user-messages');
|
|
expect(missing.body.code).toBe(40401);
|
|
|
|
const id = await createSession();
|
|
const hostile = await getJson<null>(
|
|
`/api/v1/sessions/${id}/transcript/user-messages?agent_id=${encodeURIComponent('../main')}`,
|
|
);
|
|
expect(hostile.body.code).toBe(40001);
|
|
});
|
|
|
|
it('serves plan info for an ExitPlanMode call from its approval interaction (live)', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
// Bind the transcript before publishing live events.
|
|
await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(
|
|
serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'build it' }),
|
|
);
|
|
bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 }));
|
|
bus.publish(
|
|
serverEvent({
|
|
type: 'tool.call.started',
|
|
turnId: 1,
|
|
toolCallId: 'call_plan',
|
|
name: 'ExitPlanMode',
|
|
args: {},
|
|
}),
|
|
);
|
|
|
|
const planDisplay = {
|
|
kind: 'plan_review',
|
|
plan: '# The Plan\n\nDo the thing.',
|
|
path: '/tmp/plans/foo.md',
|
|
options: [{ label: 'Approach A', description: 'fast' }],
|
|
};
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
const interactions = session!.accessor.get(ISessionInteractionService);
|
|
interactions.enqueue({
|
|
id: 'apr-plan',
|
|
kind: 'approval',
|
|
payload: {
|
|
toolCallId: 'call_plan',
|
|
toolName: 'ExitPlanMode',
|
|
action: 'Presenting plan and exiting plan mode',
|
|
display: planDisplay,
|
|
},
|
|
origin: { agentId: 'main', turnId: 1 },
|
|
});
|
|
interactions.respond('apr-plan', { decision: 'approved', selectedLabel: 'Approach A' });
|
|
|
|
const { body } = await getJson<PlanContract>(
|
|
`/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_plan`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.agent_id).toBe('main');
|
|
expect(body.data.plans).toHaveLength(1);
|
|
expect(body.data.plans[0]).toMatchObject({
|
|
tool_call_id: 'call_plan',
|
|
turn_id: 't1',
|
|
source: 'interaction',
|
|
plan: '# The Plan\n\nDo the thing.',
|
|
path: '/tmp/plans/foo.md',
|
|
options: [{ label: 'Approach A', description: 'fast' }],
|
|
review: { state: 'approved', selected_option: 'Approach A' },
|
|
});
|
|
});
|
|
|
|
it('serves plan info from the live tool frame display when no interaction exists (auto mode)', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
|
|
bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 }));
|
|
bus.publish(
|
|
serverEvent({
|
|
type: 'tool.call.started',
|
|
turnId: 1,
|
|
toolCallId: 'call_plan',
|
|
name: 'ExitPlanMode',
|
|
args: {},
|
|
display: { kind: 'plan_review', plan: '# Auto Plan', path: '/tmp/plans/auto.md' },
|
|
}),
|
|
);
|
|
bus.publish(
|
|
serverEvent({
|
|
type: 'tool.result',
|
|
turnId: 1,
|
|
toolCallId: 'call_plan',
|
|
output:
|
|
'Exited plan mode. Plan mode deactivated. All tools are now available.\nPlan saved to: /tmp/plans/auto.md\n\n## Plan (auto-approved, not user-reviewed):\n# Auto Plan',
|
|
}),
|
|
);
|
|
|
|
const { body } = await getJson<PlanContract>(
|
|
`/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_plan`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.plans).toHaveLength(1);
|
|
expect(body.data.plans[0]).toMatchObject({
|
|
source: 'display',
|
|
plan: '# Auto Plan',
|
|
path: '/tmp/plans/auto.md',
|
|
});
|
|
expect(body.data.plans[0]!.review).toBeUndefined();
|
|
});
|
|
|
|
it('rebuilds plan info from the tool result output for a cold session', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
const output =
|
|
'Exited plan mode. Plan mode deactivated. All tools are now available.\nPlan saved to: /tmp/plans/foo.md\n\n## Approved Plan:\n# The Plan\n\nDo the thing.';
|
|
await seedMainAgentMessages(id, [
|
|
{ role: 'user', content: [{ type: 'text', text: 'build it' }], toolCalls: [] },
|
|
{
|
|
role: 'assistant',
|
|
content: [],
|
|
toolCalls: [{ type: 'function', id: 'call_plan', name: 'ExitPlanMode', arguments: '{}' }],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: [{ type: 'text', text: output }],
|
|
toolCalls: [],
|
|
toolCallId: 'call_plan',
|
|
},
|
|
]);
|
|
|
|
// Reboot on the same home — the session drops out of memory (cold path).
|
|
await server!.close();
|
|
server = undefined;
|
|
await boot();
|
|
|
|
const { body } = await getJson<PlanContract>(
|
|
`/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_plan`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.agent_id).toBe('main');
|
|
expect(body.data.plans).toHaveLength(1);
|
|
expect(body.data.plans[0]).toMatchObject({
|
|
tool_call_id: 'call_plan',
|
|
source: 'output',
|
|
plan: '# The Plan\n\nDo the thing.',
|
|
path: '/tmp/plans/foo.md',
|
|
});
|
|
expect(body.data.plans[0]!.review).toBeUndefined();
|
|
});
|
|
|
|
it('rebuilds plan info from the persisted interaction for a cold session (revise)', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
await seedMainAgentMessages(id, [
|
|
{ role: 'user', content: [{ type: 'text', text: 'build it' }], toolCalls: [] },
|
|
{
|
|
role: 'assistant',
|
|
content: [],
|
|
toolCalls: [{ type: 'function', id: 'call_plan', name: 'ExitPlanMode', arguments: '{}' }],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: [{ type: 'text', text: 'User requested revisions. Plan mode remains active.' }],
|
|
toolCalls: [],
|
|
toolCallId: 'call_plan',
|
|
},
|
|
]);
|
|
|
|
// A Revise outcome: the tool result carries no plan content — only the
|
|
// persisted interaction does.
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
const interactions = session!.accessor.get(ISessionInteractionService);
|
|
interactions.enqueue({
|
|
id: 'apr-plan',
|
|
kind: 'approval',
|
|
payload: {
|
|
toolCallId: 'call_plan',
|
|
toolName: 'ExitPlanMode',
|
|
action: 'Presenting plan and exiting plan mode',
|
|
display: { kind: 'plan_review', plan: '# Draft Plan', path: '/tmp/plans/foo.md' },
|
|
},
|
|
origin: { agentId: 'main', turnId: 0 },
|
|
});
|
|
interactions.respond('apr-plan', {
|
|
decision: 'rejected',
|
|
selectedLabel: 'Revise',
|
|
feedback: 'split it up',
|
|
});
|
|
const agent = session!.accessor.get(IAgentLifecycleService).get('main');
|
|
await agent!.accessor.get(IWireService).flush();
|
|
|
|
// Reboot on the same home — the session drops out of memory (cold path).
|
|
await server!.close();
|
|
server = undefined;
|
|
await boot();
|
|
|
|
const { body } = await getJson<PlanContract>(
|
|
`/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_plan`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.plans).toHaveLength(1);
|
|
expect(body.data.plans[0]).toMatchObject({
|
|
source: 'interaction',
|
|
plan: '# Draft Plan',
|
|
path: '/tmp/plans/foo.md',
|
|
review: { state: 'rejected', selected_option: 'Revise', feedback: 'split it up' },
|
|
});
|
|
});
|
|
|
|
it('answers 40401 / 40416 / 40001 on the plan route', async () => {
|
|
const missing = await getJson<null>(
|
|
'/api/v1/sessions/nope/transcript/plan?agent_id=main&tool_call_id=call_plan',
|
|
);
|
|
expect(missing.body.code).toBe(40401);
|
|
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
|
|
const bus = mainAgentBus(id);
|
|
bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
|
|
bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 }));
|
|
bus.publish(
|
|
serverEvent({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_bash', name: 'Bash', args: {} }),
|
|
);
|
|
bus.publish(serverEvent({ type: 'tool.result', turnId: 1, toolCallId: 'call_bash', output: 'ok' }));
|
|
|
|
// Unknown tool call.
|
|
const unknown = await getJson<null>(
|
|
`/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_nope`,
|
|
);
|
|
expect(unknown.body.code).toBe(40416);
|
|
|
|
// A real tool call that is not ExitPlanMode has no plan.
|
|
const notPlan = await getJson<null>(
|
|
`/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_bash`,
|
|
);
|
|
expect(notPlan.body.code).toBe(40416);
|
|
|
|
// Hostile agent id fails validation.
|
|
const hostile = await getJson<null>(
|
|
`/api/v1/sessions/${id}/transcript/plan?agent_id=${encodeURIComponent('../main')}&tool_call_id=call_plan`,
|
|
);
|
|
expect(hostile.body.code).toBe(40001);
|
|
});
|
|
|
|
it('lists every ExitPlanMode plan of the agent when tool_call_id is omitted', async () => {
|
|
const id = await createSession();
|
|
await ensureMainAgent(id);
|
|
await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`);
|
|
|
|
const bus = mainAgentBus(id);
|
|
// Two separate ExitPlanMode calls (a Revise → resubmit pair), plus an
|
|
// unrelated tool call that must not appear.
|
|
bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }));
|
|
bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 }));
|
|
bus.publish(
|
|
serverEvent({
|
|
type: 'tool.call.started',
|
|
turnId: 1,
|
|
toolCallId: 'call_draft',
|
|
name: 'ExitPlanMode',
|
|
args: {},
|
|
display: { kind: 'plan_review', plan: '# Draft' },
|
|
}),
|
|
);
|
|
bus.publish(
|
|
serverEvent({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_bash', name: 'Bash', args: {} }),
|
|
);
|
|
bus.publish(serverEvent({ type: 'tool.result', turnId: 1, toolCallId: 'call_bash', output: 'ok' }));
|
|
bus.publish(serverEvent({ type: 'turn.step.completed', turnId: 1, step: 1 }));
|
|
bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 2 }));
|
|
bus.publish(
|
|
serverEvent({
|
|
type: 'tool.call.started',
|
|
turnId: 1,
|
|
toolCallId: 'call_final',
|
|
name: 'ExitPlanMode',
|
|
args: {},
|
|
display: { kind: 'plan_review', plan: '# Final', path: '/tmp/plans/foo.md' },
|
|
}),
|
|
);
|
|
|
|
const session = getLiveSessionById(server!.core.accessor, id);
|
|
const interactions = session!.accessor.get(ISessionInteractionService);
|
|
interactions.enqueue({
|
|
id: 'apr-final',
|
|
kind: 'approval',
|
|
payload: {
|
|
toolCallId: 'call_final',
|
|
toolName: 'ExitPlanMode',
|
|
action: 'Presenting plan and exiting plan mode',
|
|
display: { kind: 'plan_review', plan: '# Final', path: '/tmp/plans/foo.md' },
|
|
},
|
|
origin: { agentId: 'main', turnId: 1 },
|
|
});
|
|
interactions.respond('apr-final', { decision: 'approved' });
|
|
|
|
const { body } = await getJson<PlanContract>(
|
|
`/api/v1/sessions/${id}/transcript/plan?agent_id=main`,
|
|
);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.agent_id).toBe('main');
|
|
expect(body.data.plans.map((p) => [p.tool_call_id, p.plan])).toEqual([
|
|
['call_draft', '# Draft'],
|
|
['call_final', '# Final'],
|
|
]);
|
|
// The draft has no interaction; the final carries its review outcome.
|
|
expect(body.data.plans[0]!.review).toBeUndefined();
|
|
expect(body.data.plans[1]!.review).toMatchObject({ state: 'approved' });
|
|
});
|
|
});
|