mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(story-mode): a helper's own steps, from its transcript
GET /api/sessions/:id/story/helpers/:agentId draws one helper (an Agent call)
as a page from its own transcript, <conversation>/subagents/agent-<id>.jsonl
beside the session's, with the type, description and finished flag from its
.meta.json. The file is derived from the session's own transcript path and an
id checked against [A-Za-z0-9_-]{1,64}; a helper with no file yet is page
null, not an error. The reducer gains a sidechain mode (every helper line is a
sidechain line) and keeps the agentId of Agent results, so the main page links
each helper to its steps. Same gates and ETag/304 as /story, now shared.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fc279f998c
commit
fb71180632
5 files changed
+261
-63
No files matched your search
+29
-3
@@ -103,7 +103,7 @@ export type StoryEntry =
|
||||
output?: string;
|
||||
outputTruncated?: boolean;
|
||||
edit?: StoryEdit;
|
||||
helper?: { result?: string; toolUses?: number; durationMs?: number };
|
||||
helper?: { result?: string; toolUses?: number; durationMs?: number; agentId?: string };
|
||||
answer?: string;
|
||||
tests?: StoryTests;
|
||||
}
|
||||
@@ -466,8 +466,10 @@ export interface StoryReducer {
|
||||
/**
|
||||
* A reducer remembers each tool call's name and input until its result arrives, since the result line
|
||||
* does not repeat them. Bounded: a call whose result never comes is forgotten after 4,000 newer calls.
|
||||
* `sidechain` reads a helper's own transcript, where every line is a sidechain line; the main
|
||||
* transcript skips them.
|
||||
*/
|
||||
export function createStoryReducer(): StoryReducer {
|
||||
export function createStoryReducer({ sidechain = false }: { sidechain?: boolean } = {}): StoryReducer {
|
||||
const calls = new Map<string, { name: string; input: ReducedInput }>();
|
||||
const remember = (id: string, name: string, input: ReducedInput) => {
|
||||
calls.set(id, { name, input });
|
||||
@@ -504,6 +506,10 @@ export function createStoryReducer(): StoryReducer {
|
||||
result: answer ? clip(answer, STORY_LIMITS.helperChars) : undefined,
|
||||
toolUses: num(extra.totalToolUseCount),
|
||||
durationMs: num(extra.totalDurationMs),
|
||||
agentId:
|
||||
typeof extra.agentId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(extra.agentId)
|
||||
? extra.agentId
|
||||
: undefined,
|
||||
};
|
||||
} else if (name === 'AskUserQuestion' && isRecord(extra.answers)) {
|
||||
const first = Object.values(extra.answers).find((v): v is string => typeof v === 'string');
|
||||
@@ -560,7 +566,7 @@ export function createStoryReducer(): StoryReducer {
|
||||
|
||||
return {
|
||||
reduce(line: unknown): StoryEntry[] {
|
||||
if (!isRecord(line) || line.isSidechain === true) return [];
|
||||
if (!isRecord(line) || (line.isSidechain === true && !sidechain)) return [];
|
||||
const at = str(line.timestamp) ?? '';
|
||||
switch (line.type) {
|
||||
case 'user':
|
||||
@@ -703,6 +709,7 @@ function applyResult(ev: StoryEvent, r: Extract<StoryEntry, { k: 'result' }>): v
|
||||
if (r.helper.result) helper.result = r.helper.result;
|
||||
if (r.helper.toolUses !== undefined) helper.toolUses = r.helper.toolUses;
|
||||
if (r.helper.durationMs !== undefined) helper.durationMs = r.helper.durationMs;
|
||||
if (r.helper.agentId) helper.agentId = r.helper.agentId;
|
||||
ev.helper = helper;
|
||||
}
|
||||
if (r.answer && ev.question) ev.question = { ...ev.question, answer: r.answer };
|
||||
@@ -1247,6 +1254,25 @@ export function buildStoryPage(turn: StoryTurn): StoryPage {
|
||||
return { ...base, panels };
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper's own transcript as one page. Its first prompt is the job Claude gave it; a later prompt
|
||||
* (Claude messaging it again) continues the same page. `finished` comes from the helper's metadata,
|
||||
* since a helper's transcript has no `turn_duration` of its own.
|
||||
*/
|
||||
export function buildHelperPage(turns: StoryTurn[], finished?: boolean): StoryPage | null {
|
||||
if (!turns.length) return null;
|
||||
const first = turns[0];
|
||||
const last = turns[turns.length - 1];
|
||||
return buildStoryPage({
|
||||
...first,
|
||||
events: turns.flatMap((t) => t.events),
|
||||
ended: finished ?? last.ended,
|
||||
interrupted: false,
|
||||
endedAt: last.endedAt,
|
||||
durationMs: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** The turn list for the page picker, newest last, capped. */
|
||||
export function summarizeTurns(turns: StoryTurn[]): StoryTurnSummary[] {
|
||||
return turns.slice(-STORY_LIMITS.turnsListed).map(summarizeTurn);
|
||||
|
||||
+19
-11
@@ -36,6 +36,7 @@ const MAX_CHARS = 6_000_000;
|
||||
const MAX_TRANSCRIPTS = 6;
|
||||
|
||||
interface CacheEntry {
|
||||
sidechain: boolean;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
/** Byte offset up to which complete lines were consumed. */
|
||||
@@ -121,10 +122,11 @@ function ingest(entry: CacheEntry, buf: Buffer, startsMidLine: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function load(path: string): Promise<StoryEntriesResult> {
|
||||
async function load(path: string, sidechain: boolean): Promise<StoryEntriesResult> {
|
||||
const info = await stat(path);
|
||||
if (!info.isFile()) throw new Error('Transcript is not a regular file');
|
||||
const cached = cache.get(path);
|
||||
const key = sidechain ? `${path}#sidechain` : path;
|
||||
const cached = cache.get(key);
|
||||
const version = versionOf(info.size, info.mtimeMs);
|
||||
if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
|
||||
return { entries: cached.entries, partial: cached.partial, version };
|
||||
@@ -140,7 +142,7 @@ async function load(path: string): Promise<StoryEntriesResult> {
|
||||
ingest(cached, buf, false);
|
||||
cached.size = info.size;
|
||||
cached.mtimeMs = info.mtimeMs;
|
||||
cache.set(path, cached);
|
||||
cache.set(key, cached);
|
||||
return { entries: cached.entries, partial: cached.partial, version };
|
||||
}
|
||||
// First read, a rewrite, or more appended than a tail holds: start over from the tail, and take a
|
||||
@@ -150,30 +152,36 @@ async function load(path: string): Promise<StoryEntriesResult> {
|
||||
for (const tail of TAIL_STEPS) {
|
||||
const start = Math.max(0, info.size - tail);
|
||||
fresh = {
|
||||
sidechain,
|
||||
size: info.size,
|
||||
mtimeMs: info.mtimeMs,
|
||||
offset: start,
|
||||
entries: [],
|
||||
chars: 0,
|
||||
reducer: createStoryReducer(),
|
||||
reducer: createStoryReducer({ sidechain }),
|
||||
partial: start > 0,
|
||||
};
|
||||
ingest(fresh, await readRange(path, start, info.size), start > 0);
|
||||
if (start === 0 || hasTurnStart(fresh.entries)) break;
|
||||
}
|
||||
const done = fresh as CacheEntry;
|
||||
cache.set(path, done);
|
||||
cache.set(key, done);
|
||||
return { entries: done.entries, partial: done.partial, version };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduced entries for the transcript at `path`, newest last. Throws when the file cannot be read;
|
||||
* the caller turns that into an error response.
|
||||
* Reduced entries for the transcript at `path`, newest last. `sidechain` reads a helper's own
|
||||
* transcript (every line there is a sidechain line). Throws when the file cannot be read; the caller
|
||||
* turns that into an error response.
|
||||
*/
|
||||
export function readStoryEntries(path: string): Promise<StoryEntriesResult> {
|
||||
const running = inFlight.get(path);
|
||||
export function readStoryEntries(
|
||||
path: string,
|
||||
{ sidechain = false }: { sidechain?: boolean } = {}
|
||||
): Promise<StoryEntriesResult> {
|
||||
const key = sidechain ? `${path}#sidechain` : path;
|
||||
const running = inFlight.get(key);
|
||||
if (running) return running;
|
||||
const promise = load(path).finally(() => inFlight.delete(path));
|
||||
inFlight.set(path, promise);
|
||||
const promise = load(path, sidechain).finally(() => inFlight.delete(key));
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -82,6 +82,8 @@ export interface StoryHelper {
|
||||
result?: string;
|
||||
toolUses?: number;
|
||||
durationMs?: number;
|
||||
/** Claude Code's id for the helper; its own steps are at `GET /api/sessions/:id/story/helpers/<agentId>`. */
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface StoryTests {
|
||||
@@ -164,6 +166,15 @@ export interface StoryPage extends StoryTurnSummary {
|
||||
helpers: number;
|
||||
}
|
||||
|
||||
/** `GET /api/sessions/:id/story/helpers/:agentId`: one helper's own steps, as one page. */
|
||||
export type StoryHelperResponse =
|
||||
| { supported: false; reason: string }
|
||||
| {
|
||||
supported: true;
|
||||
helper: { agentId: string; type?: string; description?: string; finished?: boolean };
|
||||
page: StoryPage | null;
|
||||
};
|
||||
|
||||
export type StoryResponse =
|
||||
| { supported: false; reason: string }
|
||||
| { supported: true; turns: StoryTurnSummary[]; page: StoryPage | null };
|
||||
+111
-47
@@ -1,23 +1,36 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/sessions/:id/story`: one turn of a Claude Code session as a comic page
|
||||
* (Story Mode, `story-mode.js` in the browser). EXPERIMENTAL: the payload shape is not frozen.
|
||||
* @fileoverview Story Mode routes (`story-mode.js` in the browser). EXPERIMENTAL: payload shapes are
|
||||
* not frozen.
|
||||
*
|
||||
* Query: `turn=<id>` picks a turn from the `turns` list; without it the newest turn is drawn.
|
||||
* Answers `{ supported: false, reason }` for a session it cannot draw, decided from the CLI's
|
||||
* - `GET /api/sessions/:id/story[?turn=<id>]`: one turn of a Claude Code session as a comic page;
|
||||
* without `turn` the newest turn is drawn.
|
||||
* - `GET /api/sessions/:id/story/helpers/:agentId`: one helper's own steps (an Agent call's
|
||||
* transcript, `<conversation>/subagents/agent-<agentId>.jsonl` beside the session's), as one page.
|
||||
*
|
||||
* Both answer `{ supported: false, reason }` for a session they cannot draw, decided from the CLI's
|
||||
* registry capability (`capabilities.transcript === 'claude-jsonl'`, never the mode id) and from
|
||||
* where the transcript lives: a remote or Docker session's transcript is not on this host.
|
||||
*
|
||||
* Read-only: unlike `last-response` it never adopts a conversation id or persists anything. The file
|
||||
* Read-only: unlike `last-response` they never adopt a conversation id or persist anything. The file
|
||||
* read is the session's own transcript, chosen here and never named by the request: the path the
|
||||
* Claude hook reported for this session, else `<conversation id>.jsonl` under the projects directory.
|
||||
* A helper's file is derived from that path and an `agentId` checked against a strict charset.
|
||||
* Reading is bounded and async (story-reader.ts); what comes back is untrusted text the browser
|
||||
* renders with `textContent`, and file paths in it open through the existing file preview.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse, type StoryResponse } from '../../types.js';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
createErrorResponse,
|
||||
type ApiResponse,
|
||||
type StoryHelperResponse,
|
||||
type StoryResponse,
|
||||
} from '../../types.js';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js';
|
||||
import { buildHelperPage, buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js';
|
||||
import { readStoryEntries } from '../../story-reader.js';
|
||||
import { findClaudeTranscriptPath } from '../../utils/claude-transcript.js';
|
||||
import { findSessionOrFail } from '../route-helpers.js';
|
||||
@@ -28,6 +41,8 @@ import type { ConfigPort, SessionPort } from '../ports/index.js';
|
||||
* page an older server built.
|
||||
*/
|
||||
const SERVER_EPOCH = Date.now().toString(36);
|
||||
/** A helper's `.meta.json` is a few hundred bytes; anything bigger is not one. */
|
||||
const MAX_META_BYTES = 64 * 1024;
|
||||
|
||||
/** The hook-reported path when it looks like a transcript, else a lookup by conversation id. */
|
||||
async function resolveTranscriptPath(hookPath: string | null, conversationId: string): Promise<string | null> {
|
||||
@@ -35,58 +50,107 @@ async function resolveTranscriptPath(hookPath: string | null, conversationId: st
|
||||
return findClaudeTranscriptPath(conversationId);
|
||||
}
|
||||
|
||||
type Source = { unsupported: string } | { path: string | null };
|
||||
|
||||
/** Whether this session can be drawn at all, and where its transcript is. */
|
||||
async function storySource(ctx: SessionPort & ConfigPort, id: string, req: FastifyRequest): Promise<Source> {
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') {
|
||||
return { unsupported: 'Story Mode draws Claude Code sessions. This session runs a different CLI.' };
|
||||
}
|
||||
if (session.remote || session.docker) {
|
||||
return {
|
||||
unsupported:
|
||||
'Story Mode reads transcripts on this machine. This session keeps its transcript on another host or inside a container.',
|
||||
};
|
||||
}
|
||||
return { path: await resolveTranscriptPath(ctx.getTranscriptPath(id), session.claudeSessionId || session.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the ETag and answers true (after sending a 304) when the browser already holds this version.
|
||||
* A live view refetches every few seconds; while the file has not changed, nothing gets built.
|
||||
*/
|
||||
function notModified(req: FastifyRequest, reply: FastifyReply, tag: string): boolean {
|
||||
const etag = `W/"story-${SERVER_EPOCH}-${tag}"`;
|
||||
reply.header('ETag', etag).header('Cache-Control', 'no-cache');
|
||||
if (req.headers['if-none-match'] !== etag) return false;
|
||||
reply.code(304).send();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function readHelperMeta(path: string): Promise<{ type?: string; description?: string; finished?: boolean }> {
|
||||
try {
|
||||
if ((await stat(path)).size > MAX_META_BYTES) return {};
|
||||
const meta = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>;
|
||||
return {
|
||||
type: typeof meta.agentType === 'string' ? meta.agentType.slice(0, 80) : undefined,
|
||||
description: typeof meta.description === 'string' ? meta.description.slice(0, 300) : undefined,
|
||||
finished: typeof meta.finished === 'boolean' ? meta.finished : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & ConfigPort): void {
|
||||
app.get('/api/sessions/:id/story', async (req, reply): Promise<ApiResponse<StoryResponse>> => {
|
||||
app.get('/api/sessions/:id/story', async (req, reply): Promise<ApiResponse<StoryResponse> | void> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { turn } = req.query as { turn?: unknown };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const source = await storySource(ctx, id, req);
|
||||
if ('unsupported' in source) return { success: true, data: { supported: false, reason: source.unsupported } };
|
||||
if (!source.path) return { success: true, data: { supported: true, turns: [], page: null } };
|
||||
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') {
|
||||
return {
|
||||
success: true,
|
||||
data: { supported: false, reason: 'Story Mode draws Claude Code sessions. This session runs a different CLI.' },
|
||||
};
|
||||
}
|
||||
if (session.remote || session.docker) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
supported: false,
|
||||
reason:
|
||||
'Story Mode reads transcripts on this machine. This session keeps its transcript on another host or inside a container.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const transcriptPath = await resolveTranscriptPath(
|
||||
ctx.getTranscriptPath(id),
|
||||
session.claudeSessionId || session.id
|
||||
);
|
||||
if (!transcriptPath) return { success: true, data: { supported: true, turns: [], page: null } };
|
||||
|
||||
let entries;
|
||||
let version;
|
||||
let read;
|
||||
try {
|
||||
({ entries, version } = await readStoryEntries(transcriptPath));
|
||||
read = await readStoryEntries(source.path);
|
||||
} catch {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Could not read the session transcript');
|
||||
}
|
||||
const turnKey = typeof turn === 'string' && turn ? turn : '';
|
||||
if (notModified(req, reply, `${read.version}-${encodeURIComponent(turnKey || 'latest')}`)) return;
|
||||
|
||||
// A live view refetches every few seconds; while the transcript has not changed, answer 304
|
||||
// before building anything. `no-cache` makes the browser revalidate every time.
|
||||
const etag = `W/"story-${SERVER_EPOCH}-${version}-${encodeURIComponent(typeof turn === 'string' && turn ? turn : 'latest')}"`;
|
||||
reply.header('ETag', etag).header('Cache-Control', 'no-cache');
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
reply.code(304);
|
||||
return reply.send();
|
||||
}
|
||||
|
||||
const turns = buildStoryTurns(entries);
|
||||
const wanted = typeof turn === 'string' && turn ? turns.find((t) => t.id === turn) : turns[turns.length - 1];
|
||||
const turns = buildStoryTurns(read.entries);
|
||||
const wanted = turnKey ? turns.find((t) => t.id === turnKey) : turns[turns.length - 1];
|
||||
return {
|
||||
success: true,
|
||||
data: { supported: true, turns: summarizeTurns(turns), page: wanted ? buildStoryPage(wanted) : null },
|
||||
};
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/api/sessions/:id/story/helpers/:agentId',
|
||||
async (req, reply): Promise<ApiResponse<StoryHelperResponse> | void> => {
|
||||
const { id, agentId } = req.params as { id: string; agentId: string };
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(agentId)) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid helper id');
|
||||
}
|
||||
const source = await storySource(ctx, id, req);
|
||||
if ('unsupported' in source) return { success: true, data: { supported: false, reason: source.unsupported } };
|
||||
const helper = { agentId };
|
||||
if (!source.path) return { success: true, data: { supported: true, helper, page: null } };
|
||||
|
||||
const dir = join(dirname(source.path), basename(source.path, '.jsonl'), 'subagents');
|
||||
const path = join(dir, `agent-${agentId}.jsonl`);
|
||||
let read;
|
||||
try {
|
||||
read = await readStoryEntries(path, { sidechain: true });
|
||||
} catch {
|
||||
// Not written yet (a helper that just started), or gone: nothing to draw, not an error.
|
||||
return { success: true, data: { supported: true, helper, page: null } };
|
||||
}
|
||||
const meta = await readHelperMeta(join(dir, `agent-${agentId}.meta.json`));
|
||||
if (notModified(req, reply, `helper-${read.version}-${meta.finished ? 1 : 0}`)) return;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
supported: true,
|
||||
helper: { ...helper, ...meta },
|
||||
page: buildHelperPage(buildStoryTurns(read.entries), meta.finished),
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
* @fileoverview GET /api/sessions/:id/story (Story Mode) against invented transcripts in a temp dir,
|
||||
* plus the reader's incremental tail read. Port: N/A (app.inject()).
|
||||
*/
|
||||
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerStoryRoutes } from '../../src/web/routes/story-routes.js';
|
||||
import { clearStoryReaderCache, readStoryEntries } from '../../src/story-reader.js';
|
||||
import { bash, demoTurn, end, prompt, read, resetClock, said } from '../story-fixtures.js';
|
||||
import { bash, demoTurn, edit, end, prompt, read, resetClock, result, said, tool } from '../story-fixtures.js';
|
||||
|
||||
const URL = '/api/sessions/test-session-1/story';
|
||||
const jsonl = (lines: unknown[]) => lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
|
||||
@@ -167,6 +167,95 @@ describe('GET /api/sessions/:id/story', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/story/helpers/:agentId', () => {
|
||||
const side = (lines: unknown[]) => lines.map((l) => ({ ...(l as object), isSidechain: true }));
|
||||
|
||||
it("draws a helper's own steps, and the main page carries its agentId", async () => {
|
||||
writeFileSync(
|
||||
transcript,
|
||||
jsonl([
|
||||
prompt('Write the docs with a helper'),
|
||||
tool('a1', 'Agent', {
|
||||
description: 'Write README',
|
||||
subagent_type: 'general-purpose',
|
||||
prompt: 'Write README.md',
|
||||
}),
|
||||
result('a1', 'Done', {
|
||||
toolUseResult: {
|
||||
status: 'completed',
|
||||
agentId: 'abc123',
|
||||
content: [{ type: 'text', text: 'README written.' }],
|
||||
totalToolUseCount: 2,
|
||||
},
|
||||
}),
|
||||
end(5000),
|
||||
])
|
||||
);
|
||||
const sub = join(dir, 'conversation', 'subagents');
|
||||
mkdirSync(sub, { recursive: true });
|
||||
writeFileSync(
|
||||
join(sub, 'agent-abc123.jsonl'),
|
||||
jsonl(
|
||||
side([
|
||||
prompt('Write README.md for the script'),
|
||||
...read('r1', '/w/script.sh'),
|
||||
...edit('e1', '/w/README.md', ['+# Script', '+Usage'], 1),
|
||||
])
|
||||
)
|
||||
);
|
||||
writeFileSync(
|
||||
join(sub, 'agent-abc123.meta.json'),
|
||||
JSON.stringify({ agentType: 'general-purpose', description: 'Write README', finished: true })
|
||||
);
|
||||
const { app } = await setup();
|
||||
|
||||
const main = (await app.inject({ method: 'GET', url: URL })).json().data.page;
|
||||
expect(main.events.find((e: { helper?: unknown }) => e.helper).helper).toMatchObject({
|
||||
agentId: 'abc123',
|
||||
toolUses: 2,
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}/helpers/abc123` });
|
||||
const data = res.json().data;
|
||||
expect(data.helper).toEqual({
|
||||
agentId: 'abc123',
|
||||
type: 'general-purpose',
|
||||
description: 'Write README',
|
||||
finished: true,
|
||||
});
|
||||
expect(data.page.prompt).toBe('Write README.md for the script');
|
||||
expect(data.page.panels.map((p: { kind: string }) => p.kind)).toEqual([
|
||||
'mission',
|
||||
'investigate',
|
||||
'fix',
|
||||
'complete',
|
||||
]);
|
||||
expect(res.headers.etag).toMatch(/^W\/"story-/);
|
||||
const again = await app.inject({
|
||||
method: 'GET',
|
||||
url: `${URL}/helpers/abc123`,
|
||||
headers: { 'if-none-match': res.headers.etag as string },
|
||||
});
|
||||
expect(again.statusCode).toBe(304);
|
||||
});
|
||||
|
||||
it('answers page null for a helper that has not written anything yet', async () => {
|
||||
writeFileSync(transcript, jsonl(demoTurn()));
|
||||
const { app } = await setup();
|
||||
const data = (await app.inject({ method: 'GET', url: `${URL}/helpers/notyet1` })).json().data;
|
||||
expect(data).toEqual({ supported: true, helper: { agentId: 'notyet1' }, page: null });
|
||||
});
|
||||
|
||||
it('refuses an id that could leave the subagents folder', async () => {
|
||||
const { app } = await setup();
|
||||
for (const bad of ['..', '..%2F..%2Fsecret', 'a.b', 'x'.repeat(65)]) {
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}/helpers/${bad}` });
|
||||
// `..` never reaches the route (the URL is normalized first: 404); the rest hit the id check (400).
|
||||
expect([400, 404], bad).toContain(res.statusCode);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('readStoryEntries', () => {
|
||||
it('leaves a line that is still being written for the next read', async () => {
|
||||
const full = JSON.stringify(prompt('half written'));
|
||||
|
||||
Reference in new issue
Block a user