Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
d846424
List sessions over HTTP and name reply speed in plain language
charneykaye Oct 3, 2026
3a967a3
Make a session resumable and put the first session in plain language
charneykaye Oct 3, 2026
6a9601c
Let the tutorial answer the player's questions, in the player's words
charneykaye Oct 3, 2026
db56ef3
Unit-test whole-turn numbering and the plain-language helpers
charneykaye Oct 3, 2026
6e31a3a
Move the resume and familiarity strings to the top of the catalogs
charneykaye Oct 3, 2026
c5b5bbc
Test the reply-speed and wording controls
charneykaye Oct 3, 2026
8ce41e1
Apply the tutorial rewrite to the copy that actually plays
charneykaye Oct 3, 2026
421154e
Document whole turns, session listing, resume and reply speed
charneykaye Oct 3, 2026
782765e
Cover the resume entry points and the debrief's whole-turn labels
charneykaye Oct 3, 2026
b5ace5d
Bring the player acceptance checklist up to the new conversation screen
charneykaye Oct 3, 2026
79a04b1
Name the meters toggle after the panel it switches on
charneykaye Oct 3, 2026
9c8a30f
Quote the final meters-toggle wording in the player checklist
charneykaye Oct 3, 2026
a49cecf
Separate the reply-speed helper from the module constants
charneykaye Oct 3, 2026
9fea5ef
Merge remote-tracking branch 'origin/main' into yoke/issue-501-fix-fi…
charneykaye Oct 3, 2026
343e38d
Repair the origin/main merge, which half-reverted the conversation sc…
charneykaye Oct 3, 2026
ae1894e
Teach the TypeScript API proxy about reply speed
charneykaye Oct 3, 2026
7791ee9
Merge remote-tracking branch 'origin/main' into pr-550
charneykaye Oct 3, 2026
929598d
Make the session listing tell the truth about finished and preview se…
charneykaye Oct 3, 2026
19b905a
Number the debrief's turn references in whole turns
charneykaye Oct 3, 2026
124811d
Document what the session listing actually returns
charneykaye Oct 3, 2026
7c9d6cf
Answer the player's question without charging them a tour beat
charneykaye Oct 3, 2026
a37fdca
Stop the reply-speed clamps from overriding the author's word cap
charneykaye Oct 3, 2026
72408ce
Show the note the tutorial promises when a scenario event fires
charneykaye Oct 3, 2026
9a3a246
Say "1 turn" on the resume strip after a single exchange
charneykaye Oct 3, 2026
58fe2e9
Cover the meters a resumed conversation has to redraw
charneykaye Oct 3, 2026
9169635
Say the tutorial's debrief in the player's words too
charneykaye Oct 3, 2026
aec19f0
Translate the debrief transcript's mood label
charneykaye Oct 3, 2026
39c94fc
Honour status and limit in the TypeScript session listing too
charneykaye Oct 3, 2026
c42abda
Show the meters the tutorial promises, by default
charneykaye Oct 3, 2026
997898f
Translate the plain session states on the Settings screen
charneykaye Oct 3, 2026
223bac0
Report the resumed conversation's meters from the TypeScript proxy too
charneykaye Oct 3, 2026
0051765
Say "engine" instead of "runtime" on the two controls a player sees
charneykaye Oct 3, 2026
b3a7f8f
Show the debrief the resume strip's "End it" just produced
charneykaye Oct 3, 2026
67a6cfd
Keep numbering whole turns when a resumed session saves no transcript
charneykaye Oct 3, 2026
fd63e1f
Say "AI engine" and "AI model" on the status rows a player reads
charneykaye Oct 3, 2026
d74c045
Keep the resumable-state list in one place per language
charneykaye Oct 3, 2026
fb27d5a
Call the meters the same thing on the debrief as in the conversation
charneykaye Oct 3, 2026
ea17c72
Cover the link that bridges a slow turn to the reply-speed setting
charneykaye Oct 3, 2026
9755779
Point the screenshot capture at the renamed meters toggle
charneykaye Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions apps/api/src/routes/runtime-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('GET /api/runtime/settings', () => {
expect(body.settings.temperature).toBeNull();
expect(body.settings.top_p).toBeNull();
expect(body.settings.repeat_penalty).toBeNull();
expect(body.settings.reply_speed).toBeNull();
});

it('returns requires_restart: false on initial GET', async () => {
Expand All @@ -47,6 +48,7 @@ describe('GET /api/runtime/settings', () => {
temperature: null,
top_p: null,
repeat_penalty: null,
reply_speed: null,
});
});
});
Expand Down Expand Up @@ -314,6 +316,79 @@ describe('POST /api/runtime/settings/reset', () => {
});
});

// ── Reply speed (issue #501) ─────────────────────────────────────────────────

describe('runtime settings — reply_speed', () => {
it('persists a chosen speed', async () => {
const res = await app.inject({
method: 'PUT',
url: '/api/runtime/settings',
payload: { reply_speed: 'fast' },
});
expect(res.statusCode).toBe(200);
expect(res.json<RuntimeSettingsResponse>().settings.reply_speed).toBe('fast');

const get = await app.inject({ method: 'GET', url: '/api/runtime/settings' });
expect(get.json<RuntimeSettingsResponse>().settings.reply_speed).toBe('fast');
});

it('returns requires_restart: false — it is read per turn', async () => {
const res = await app.inject({
method: 'PUT',
url: '/api/runtime/settings',
payload: { reply_speed: 'detailed' },
});
expect(res.json<RuntimeSettingsResponse>().requires_restart).toBe(false);
});

it('returns 422 for a speed the UI cannot render', async () => {
const res = await app.inject({
method: 'PUT',
url: '/api/runtime/settings',
payload: { reply_speed: 'ludicrous' },
});
expect(res.statusCode).toBe(422);
expect(res.json<{ message: string }>().message).toMatch(/Reply speed/);
});

it('survives a partial update of the numeric settings', async () => {
await app.inject({
method: 'PUT',
url: '/api/runtime/settings',
payload: { reply_speed: 'fast' },
});
await app.inject({
method: 'PUT',
url: '/api/runtime/settings',
payload: { threads: 4 },
});
const res = await app.inject({ method: 'GET', url: '/api/runtime/settings' });
const body = res.json<RuntimeSettingsResponse>();
expect(body.settings.reply_speed).toBe('fast');
expect(body.settings.threads).toBe(4);
});

it('is cleared by reset', async () => {
await app.inject({
method: 'PUT',
url: '/api/runtime/settings',
payload: { reply_speed: 'fast' },
});
const res = await app.inject({ method: 'POST', url: '/api/runtime/settings/reset' });
expect(res.json<RuntimeSettingsResponse>().settings.reply_speed).toBeNull();
});

it('reads a stored value it does not recognise back as unset', async () => {
// A value a newer build wrote, or one hand-edited into the database, must
// not surface as an option the UI cannot render.
getDb()
.prepare('INSERT OR REPLACE INTO model_config (key, value) VALUES (?, ?)')
.run('setting.reply_speed', 'hypersonic');
const res = await app.inject({ method: 'GET', url: '/api/runtime/settings' });
expect(res.json<RuntimeSettingsResponse>().settings.reply_speed).toBeNull();
});
});

// ── Persistence across buildApp calls (simulating restarts) ──────────────────

describe('runtime settings — persistence across restarts', () => {
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/routes/runtime-settings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
import type { FastifyInstance } from 'fastify';
import { getDb } from '../db.js';
import { REPLY_SPEEDS, isReplySpeed } from '@convsim/shared';
import type {
ReplySpeed,
RuntimeSettings,
RuntimeSettingsResponse,
RuntimeSettingsRequest,
Expand All @@ -15,8 +17,11 @@ const SETTING_KEYS: (keyof RuntimeSettings)[] = [
'temperature',
'top_p',
'repeat_penalty',
'reply_speed',
];

// reply_speed is deliberately absent: it is read per turn, so a change applies
// to the player's very next message with no restart.
const RESTART_REQUIRED: Set<keyof RuntimeSettings> = new Set(['context_length', 'gpu_layers']);

const NULL_SETTINGS: RuntimeSettings = {
Expand All @@ -26,6 +31,7 @@ const NULL_SETTINGS: RuntimeSettings = {
temperature: null,
top_p: null,
repeat_penalty: null,
reply_speed: null,
};

interface ModelConfigRow { key: string; value: string }
Expand All @@ -45,13 +51,21 @@ export function loadRuntimeSettings(): RuntimeSettings {
return isNaN(n) ? null : n;
}

function speed(k: string): ReplySpeed | null {
// A value a newer build wrote, or one hand-edited into the database, must
// not surface as an option the UI cannot render.
const v = map[k];
return isReplySpeed(v) ? v : null;
}

return {
context_length: num('context_length'),
gpu_layers: num('gpu_layers'),
threads: num('threads'),
temperature: num('temperature'),
top_p: num('top_p'),
repeat_penalty: num('repeat_penalty'),
reply_speed: speed('reply_speed'),
};
}

Expand Down Expand Up @@ -126,6 +140,16 @@ function validate(req: RuntimeSettingsRequest): RuntimeSettingsFieldError[] {
}
}

const speed = req.reply_speed;
if (speed !== null && speed !== undefined) {
if (!isReplySpeed(speed)) {
errs.push({
field: 'reply_speed',
message: `Reply speed must be one of: ${[...REPLY_SPEEDS].sort().join(', ')}.`,
});
}
}

return errs;
}

Expand Down Expand Up @@ -159,6 +183,7 @@ export async function runtimeSettingsRoutes(app: FastifyInstance): Promise<void>
temperature: { type: ['number', 'null'] },
top_p: { type: ['number', 'null'] },
repeat_penalty: { type: ['number', 'null'] },
reply_speed: { type: ['string', 'null'] },
},
},
},
Expand Down
79 changes: 79 additions & 0 deletions apps/api/src/routes/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,62 @@ describe('GET /api/sessions', () => {
const res = await app.inject({ method: 'GET', url: '/api/sessions' });
expect(res.json<{ sessions: unknown[] }>().sessions).toHaveLength(0);
});

// ── status / limit (issue #501 §1) ────────────────────────────────────────
// The resume affordances ask for `status=in_progress&limit=1` and offer
// whatever comes back as "Conversation in progress". A listing that ignored
// the filter would offer to resume a conversation that had already ended, so
// this proxy honours the same contract convsim-core serves.

async function createInState(state: string): Promise<string> {
const r = await app.inject({ method: 'POST', url: '/api/sessions', payload: validRequest });
const { session_id } = r.json<SessionCreateResponse>();
getDb().prepare('UPDATE sessions SET state = ? WHERE session_id = ?').run(state, session_id);
return session_id;
}

it('status=in_progress returns only sessions that can be resumed', async () => {
const live = await createInState('PlayerTurnListening');
await createInState('NotStarted');
await createInState('Ended');

const res = await app.inject({ method: 'GET', url: '/api/sessions?status=in_progress' });
const { sessions } = res.json<{ sessions: SessionCreateResponse[] }>();
expect(sessions.map((s) => s.session_id)).toEqual([live]);
});

it('status=ended covers the debrief states, not just Ended', async () => {
const ended = await createInState('Ended');
const generating = await createInState('DebriefGenerating');
const ready = await createInState('DebriefReady');
const errored = await createInState('Error');
await createInState('PlayerTurnListening');

const res = await app.inject({ method: 'GET', url: '/api/sessions?status=ended' });
const { sessions } = res.json<{ sessions: SessionCreateResponse[] }>();
expect(sessions.map((s) => s.session_id).sort()).toEqual(
[ended, generating, ready, errored].sort(),
);
});

it('limit caps the listing, newest first', async () => {
await createInState('PlayerTurnListening');
const newest = await createInState('PlayerTurnListening');

const res = await app.inject({ method: 'GET', url: '/api/sessions?limit=1' });
const { sessions } = res.json<{ sessions: SessionCreateResponse[] }>();
expect(sessions.map((s) => s.session_id)).toEqual([newest]);
});

it('rejects an out-of-range limit and an unknown status', async () => {
expect((await app.inject({ method: 'GET', url: '/api/sessions?limit=0' })).statusCode).toBe(400);
expect((await app.inject({ method: 'GET', url: '/api/sessions?limit=501' })).statusCode).toBe(
400,
);
expect((await app.inject({ method: 'GET', url: '/api/sessions?status=live' })).statusCode).toBe(
400,
);
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -272,6 +328,29 @@ describe('GET /api/sessions/:session_id', () => {
});
expect(res.statusCode).toBe(404);
});

it('reports the meter values a resuming screen has to redraw', async () => {
// Issue #501 §1: meter values otherwise only ever arrive with a turn, so a
// player who stepped out of a conversation and came back saw no meters at
// all until they sent another message.
const createRes = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: validRequest,
});
const { session_id } = createRes.json<SessionCreateResponse>();
await app.inject({ method: 'POST', url: `/api/sessions/${session_id}/start` });
await app.inject({
method: 'POST',
url: `/api/sessions/${session_id}/turn`,
payload: { content: 'Hello there.' },
});

const getRes = await app.inject({ method: 'GET', url: `/api/sessions/${session_id}` });
const body = getRes.json<SessionCreateResponse>();
expect(body.visible_state).toBeTruthy();
expect(Object.keys(body.visible_state ?? {}).length).toBeGreaterThan(0);
});
});

// ---------------------------------------------------------------------------
Expand Down
53 changes: 48 additions & 5 deletions apps/api/src/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import type {
SessionTranscriptResponse,
EndingType,
} from '@convsim/shared';
// The resumable / ended partition lives in @convsim/shared so this listing and
// the Settings screen that reads it cannot drift apart (issue #501 §1).
import { ENDED_SESSION_STATES, RESUMABLE_SESSION_STATES } from '@convsim/shared';
import { findScenarioInfo } from './scenarios.js';
import { getDb, WORKBENCH_TEST_SCENARIO_ID } from '../db.js';
import { broadcast, closeSessionSockets } from '../ws/session-events.js';
Expand Down Expand Up @@ -163,16 +166,48 @@ function rejectTransition(reply: { status: (code: number) => void }, state: Sess

export async function sessionRoutes(app: FastifyInstance) {
// GET /api/sessions
app.get('/api/sessions', async (): Promise<{ sessions: SessionCreateResponse[] }> => {
app.get<{ Querystring: { status?: string; limit?: string } }>(
'/api/sessions',
async (request, reply): Promise<{ sessions: SessionCreateResponse[] }> => {
const db = getDb();
// `status` and `limit` are the same contract convsim-core serves
// (issue #501 §1). Honouring them here is not cosmetic: the resume
// affordances ask for `status=in_progress&limit=1` and offer whatever comes
// back as "Conversation in progress", so a listing that silently ignored
// the filter would offer to resume a conversation that has already ended.
const status = request.query.status ?? 'all';
if (status !== 'all' && status !== 'in_progress' && status !== 'ended') {
reply.status(400);
throw new Error("status must be one of: all, ended, in_progress");
}
const limit = request.query.limit === undefined ? 50 : Number(request.query.limit);
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
reply.status(400);
throw new Error('limit must be between 1 and 500');
}

// Exclude temporary workbench test sessions so they never pollute the
// player's session history (they are cleaned up on discard/reset/unmount,
// but a lingering row must not surface here either).
const states =
status === 'in_progress'
? RESUMABLE_SESSION_STATES
: status === 'ended'
? ENDED_SESSION_STATES
: null;
const clauses = ['scenario_id != ?'];
const params: (string | number)[] = [WORKBENCH_TEST_SCENARIO_ID];
if (states !== null) {
clauses.push(`state IN (${states.map(() => '?').join(', ')})`);
params.push(...states);
}
params.push(limit);
const rows = db
.prepare<[string], SessionRow>(
'SELECT * FROM sessions WHERE scenario_id != ? ORDER BY created_at DESC, rowid DESC',
.prepare<(string | number)[], SessionRow>(
`SELECT * FROM sessions WHERE ${clauses.join(' AND ')} ` +
'ORDER BY created_at DESC, rowid DESC LIMIT ?',
)
.all(WORKBENCH_TEST_SCENARIO_ID);
.all(...params);
return {
sessions: rows.map((row) => ({
session_id: row.session_id,
Expand All @@ -185,7 +220,8 @@ export async function sessionRoutes(app: FastifyInstance) {
setup: JSON.parse(row.setup_json) as SessionCreateRequest,
})),
};
});
},
);

// POST /api/sessions
app.post<{ Body: SessionCreateRequest }>(
Expand Down Expand Up @@ -299,6 +335,13 @@ export async function sessionRoutes(app: FastifyInstance) {
state: row.state as SessionState,
created_at: row.created_at,
setup: JSON.parse(row.setup_json) as SessionCreateRequest,
// A resuming conversation screen draws its meters from here
// (issue #501 §1): meter values otherwise only ever arrive with a turn,
// so without them a player who stepped out and came back saw no meters
// until they sent another message. Mirrors convsim-core's single-session
// response; this layer does not model per-variable visibility, so every
// tracked variable is reported.
visible_state: JSON.parse(row.state_vars_json || '{}') as Record<string, number>,
};
},
);
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ describe('App shell', () => {
it('renders Conversation at /conversation/:id', () => {
renderAt('/conversation/sess-001')
expect(screen.getByRole('heading', { name: /conversation/i })).toBeInTheDocument()
expect(screen.getByText(/sess-001/)).toBeInTheDocument()
// The session id is a technical-wording detail now (issue #501 §2), so the
// route is identified by the screen it mounted, not by the id in its header.
expect(screen.getByTestId('conversation-page')).toBeInTheDocument()
})

it('renders Debrief at /debrief/:id', () => {
Expand Down Expand Up @@ -111,13 +113,13 @@ describe('App shell', () => {
it('shows healthy status when backend returns ok', async () => {
mockFetch({ status: 'ok' })
renderAt('/')
expect(await screen.findByText('Local runtime: Ready')).toBeInTheDocument()
expect(await screen.findByText('AI engine: Ready')).toBeInTheDocument()
})

it('shows unavailable status when backend is unreachable', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('Network error'))))
renderAt('/')
expect(await screen.findByText('Local runtime: Unavailable')).toBeInTheDocument()
expect(await screen.findByText('AI engine: Unavailable')).toBeInTheDocument()
})
})

Expand Down
Loading
Loading