Skip to content

Commit 73fc5ff

Browse files
committed
fix(workbench): mirror the daemon's settle announcements, pre-dispatch refusals, cancelled settles, and admit order in the dev mock
1 parent f695dd8 commit 73fc5ff

3 files changed

Lines changed: 177 additions & 46 deletions

File tree

packages/client/workbench/src/mock/data/prompt.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export const MOCK_USAGE_REPORT: UsageReport = {
6161

6262
/** Prompting exactly this text forces the error path (the platform mocks' `?outcome=` analog). */
6363
export const FAIL_PROMPT = 'fail';
64+
/** Prompting exactly this text is refused before dispatch: the tree gains a failed sibling, the
65+
* default leaf stays, and the reply is the typed failure (the daemon's `resolveFailed` shape). */
66+
export const REFUSE_PROMPT = 'refuse';
67+
export const REFUSE_MESSAGE = `Mock refusal requested via the "${REFUSE_PROMPT}" prompt.`;
6468

6569
/** Simulated round-trip on control ops so list/start loading states stay visible in dev. */
6670
export const CONTROL_LATENCY_MS = 300;

packages/client/workbench/src/mock/dev-mock-host.ts

Lines changed: 98 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ import {
7878
FAIL_PROMPT,
7979
MOCK_REPLY,
8080
MOCK_USAGE_REPORT,
81+
REFUSE_MESSAGE,
82+
REFUSE_PROMPT,
8183
WORD_CHUNK_PATTERN,
8284
} from './data/prompt';
8385
import { mockScriptDeclarations } from './data/scripts';
@@ -178,6 +180,8 @@ interface MockTurn {
178180
readContent?: ContentBlock[];
179181
}
180182

183+
type MockReplyOutcome = { state: 'completed' | 'cancelled' } | { state: 'failed'; message: string };
184+
181185
interface MockJournalEntry {
182186
epoch: number;
183187
seq: number;
@@ -1368,7 +1372,7 @@ export class DevMockHost {
13681372
case 'shell-command': {
13691373
const content = [textBlock(`$ ${input.command}`)];
13701374
const turn = this.beginTurn(session, content, input);
1371-
settleTurn(session, turn, 'completed');
1375+
this.settleTurn(session, turn, 'completed');
13721376
this.sendSuccess(replyTo);
13731377
break;
13741378
}
@@ -1420,7 +1424,7 @@ export class DevMockHost {
14201424
}
14211425
session.status = 'idle';
14221426
this.emit(session.sessionId, { type: 'status', status: 'idle' });
1423-
settleTurn(session, turn, 'completed');
1427+
this.settleTurn(session, turn, 'completed');
14241428
this.sendSuccess(replyTo);
14251429
}
14261430

@@ -1440,14 +1444,10 @@ export class DevMockHost {
14401444
this.sendFailure(p.clientReqId, `Session is busy: ${p.sessionId}`, { code: 'busy' });
14411445
return;
14421446
}
1443-
// Explicit-parent submits carry the daemon's admit rules: the revision must match, the parent
1444-
// must exist and have completed. `null` starts a new root lineage.
1447+
// Explicit-parent submits carry the daemon's admit rules in its order: the parent must exist
1448+
// and have completed, then the revision must match. `null` starts a new root lineage.
14451449
let parentTurnId: TurnId | null | undefined;
14461450
if (p.parentTurnId !== undefined) {
1447-
if (p.expectedGraphRevision !== session.graphRevision) {
1448-
this.sendFailure(p.clientReqId, 'The conversation graph has moved', { code: 'conflict' });
1449-
return;
1450-
}
14511451
if (p.parentTurnId !== null) {
14521452
const parent = session.graphTurns.find((turn) => turn.graph.turnId === p.parentTurnId);
14531453
if (parent === undefined) {
@@ -1463,6 +1463,10 @@ export class DevMockHost {
14631463
return;
14641464
}
14651465
}
1466+
if (p.expectedGraphRevision !== session.graphRevision) {
1467+
this.sendFailure(p.clientReqId, 'The conversation graph has moved', { code: 'conflict' });
1468+
return;
1469+
}
14661470
parentTurnId = p.parentTurnId;
14671471
}
14681472
if (p.input.type === 'prompt') {
@@ -1490,6 +1494,12 @@ export class DevMockHost {
14901494
}
14911495
}
14921496
const content = turnSubmitContent(p.input);
1497+
if (p.input.type === 'prompt' && promptText(content).toLowerCase() === REFUSE_PROMPT) {
1498+
const refused = this.refuseTurn(session, content, parentTurnId);
1499+
refused.readContent = this.projectTurnSubmit(p.input);
1500+
this.sendFailure(p.clientReqId, REFUSE_MESSAGE, { code: 'operation_failed' });
1501+
return;
1502+
}
14931503
const turn = this.beginTurn(
14941504
session,
14951505
content,
@@ -1500,18 +1510,17 @@ export class DevMockHost {
15001510
this.send({ kind: 'turn.submitted', replyTo: p.clientReqId, turnId: turn.graph.turnId });
15011511
if (p.input.type === 'prompt') {
15021512
const result = await this.streamMockReply(session, content);
1503-
settleTurn(session, turn, result.ok ? 'completed' : 'failed');
1504-
if (!result.ok) this.announceGraphShape(session);
1513+
this.settleTurn(session, turn, result.state);
15051514
return;
15061515
}
15071516
// Command/shell turns just echo — the mock has no directive execution behind turn.submit.
1508-
settleTurn(session, turn, 'completed');
1517+
this.settleTurn(session, turn, 'completed');
15091518
}
15101519

1511-
/** A turn failed after it began: the tree changed shape but the default leaf stays — the daemon
1512-
* announces the same way from `resolveFailed`. */
1513-
private announceGraphShape(session: MockSession): void {
1514-
session.graphRevision += 1;
1520+
/** Every device refetches the tree: a node it did not have moves the revision; a settle keeps
1521+
* it — the badge is what changed (the daemon's `announceGraph`). */
1522+
private announceGraph(session: MockSession, gainedNode: boolean): void {
1523+
if (gainedNode) session.graphRevision += 1;
15151524
this.send({
15161525
kind: 'conversation.graph.changed',
15171526
sessionId: session.sessionId,
@@ -1520,18 +1529,28 @@ export class DevMockHost {
15201529
});
15211530
}
15221531

1523-
/** Mint the graph turn a turn-starting input persists on the daemon (legacy inputs included)
1524-
* and point the frames that follow at it. */
1525-
private beginTurn(
1532+
private settleTurn(
1533+
session: MockSession,
1534+
turn: MockTurn,
1535+
state: 'completed' | 'failed' | 'cancelled',
1536+
): void {
1537+
turn.graph.state = state;
1538+
if (session.runningTurnId === turn.graph.turnId) session.runningTurnId = undefined;
1539+
this.announceGraph(session, false);
1540+
}
1541+
1542+
/** Persist a graph turn the way the daemon does before dispatch: a plain send lands under the
1543+
* active leaf, an explicit parent lands a sibling (or a root). */
1544+
private mintTurn(
15261545
session: MockSession,
15271546
content: ContentBlock[],
1528-
input?: Exclude<TurnSubmitInput, { type: 'prompt' }>,
1529-
parentTurnId?: TurnId | null,
1547+
input: Exclude<TurnSubmitInput, { type: 'prompt' }> | undefined,
1548+
parentTurnId: TurnId | null | undefined,
1549+
state: 'running' | 'failed',
15301550
): MockTurn {
15311551
this.turnSeq += 1;
15321552
const id = this.turnSeq.toString(36);
15331553
const turnId = `turn-mock-${id}` as TurnId;
1534-
// A plain send lands under the active leaf; an explicit parent lands a sibling (or a root).
15351554
const parent = parentTurnId === undefined ? (session.activeLeafTurnId ?? null) : parentTurnId;
15361555
const siblingOrdinal =
15371556
session.graphTurns.filter((turn) => turn.graph.parentTurnId === parent).length + 1;
@@ -1543,15 +1562,27 @@ export class DevMockHost {
15431562
siblingOrdinal,
15441563
input: input ?? { type: 'prompt', promptId: `prompt-mock-${id}` as PromptId },
15451564
runId: `run-mock-${id}` as RunId,
1546-
state: 'running',
1565+
state,
15471566
createdAt: Date.now(),
15481567
inputSummary: promptText(content).slice(0, 140),
15491568
},
15501569
content,
15511570
};
15521571
session.graphTurns.push(turn);
1572+
return turn;
1573+
}
1574+
1575+
/** Mint the graph turn a turn-starting input persists on the daemon (legacy inputs included)
1576+
* and point the frames that follow at it: the default leaf moves as the turn commits running. */
1577+
private beginTurn(
1578+
session: MockSession,
1579+
content: ContentBlock[],
1580+
input?: Exclude<TurnSubmitInput, { type: 'prompt' }>,
1581+
parentTurnId?: TurnId | null,
1582+
): MockTurn {
1583+
const turn = this.mintTurn(session, content, input, parentTurnId, 'running');
1584+
const { turnId } = turn.graph;
15531585
session.activeLeafTurnId = turnId;
1554-
session.graphRevision += 1;
15551586
session.runningTurnId = turnId;
15561587
// Echo before graph.changed so a subscribed projection store sees the new leaf row and
15571588
// treats a plain send as continuation, matching the engine dispatcher.
@@ -1560,12 +1591,20 @@ export class DevMockHost {
15601591
messageId: userRowMessageId(turnId),
15611592
content,
15621593
});
1563-
this.send({
1564-
kind: 'conversation.graph.changed',
1565-
sessionId: session.sessionId,
1566-
graphRevision: session.graphRevision,
1567-
activeLeafTurnId: turnId,
1568-
});
1594+
this.announceGraph(session, true);
1595+
return turn;
1596+
}
1597+
1598+
/** A prompt the provider refuses before it runs — the daemon's `resolveFailed` shape: the tree
1599+
* gains a failed node and announces it, the default leaf stays, and nothing is echoed live. */
1600+
private refuseTurn(
1601+
session: MockSession,
1602+
content: ContentBlock[],
1603+
parentTurnId: TurnId | null | undefined,
1604+
): MockTurn {
1605+
const turn = this.mintTurn(session, content, undefined, parentTurnId, 'failed');
1606+
turn.readContent = content;
1607+
this.announceGraph(session, true);
15691608
return turn;
15701609
}
15711610

@@ -1585,19 +1624,26 @@ export class DevMockHost {
15851624
return;
15861625
}
15871626
}
1627+
if (promptText(content).toLowerCase() === REFUSE_PROMPT) {
1628+
this.refuseTurn(session, content, undefined);
1629+
this.sendFailure(replyTo, REFUSE_MESSAGE, { code: 'operation_failed' });
1630+
return;
1631+
}
15881632
const turn = this.beginTurn(session, content);
15891633
turn.readContent = await this.ingestInlineImages(session.sessionId, content);
15901634
const result = await this.streamMockReply(session, content);
1591-
settleTurn(session, turn, result.ok ? 'completed' : 'failed');
1592-
if (!result.ok) this.announceGraphShape(session);
1593-
if (result.ok) this.sendSuccess(replyTo);
1594-
else this.sendFailure(replyTo, result.message, { reportedInConversation: true });
1635+
this.settleTurn(session, turn, result.state);
1636+
if (result.state === 'failed') {
1637+
this.sendFailure(replyTo, result.message, { reportedInConversation: true });
1638+
} else {
1639+
this.sendSuccess(replyTo);
1640+
}
15951641
}
15961642

15971643
private async streamMockReply(
15981644
session: MockSession,
15991645
content: ContentBlock[],
1600-
): Promise<{ ok: true } | { ok: false; message: string }> {
1646+
): Promise<MockReplyOutcome> {
16011647
const text = promptText(content);
16021648
if (text && !session.title) session.title = text.slice(0, 80);
16031649
session.status = 'running';
@@ -1611,7 +1657,7 @@ export class DevMockHost {
16111657
return session.epoch !== epoch;
16121658
};
16131659

1614-
if (await cancelledAfter(200)) return { ok: true };
1660+
if (await cancelledAfter(200)) return { state: 'cancelled' };
16151661
const thoughtId = this.nextMessageId('mock-thought');
16161662
this.emit(session.sessionId, {
16171663
type: 'agent-thought-chunk',
@@ -1620,7 +1666,7 @@ export class DevMockHost {
16201666
});
16211667

16221668
if (text.toLowerCase() === FAIL_PROMPT) {
1623-
if (await cancelledAfter(200)) return { ok: true };
1669+
if (await cancelledAfter(200)) return { state: 'cancelled' };
16241670
const message = `Mock failure requested via the "${FAIL_PROMPT}" prompt.`;
16251671
this.emit(session.sessionId, {
16261672
type: 'error',
@@ -1630,7 +1676,7 @@ export class DevMockHost {
16301676
});
16311677
session.status = 'idle';
16321678
this.emit(session.sessionId, { type: 'status', status: 'idle' });
1633-
return { ok: false, message };
1679+
return { state: 'failed', message };
16341680
}
16351681

16361682
const messageId = this.nextMessageId('mock-message');
@@ -1639,7 +1685,7 @@ export class DevMockHost {
16391685
if (chunks != null) {
16401686
for (let i = 0, len = chunks.length; i < len; i++) {
16411687
// eslint-disable-next-line no-await-in-loop -- word-by-word streaming: chunks are paced sequentially by design.
1642-
if (await cancelledAfter(CHUNK_LATENCY_MS)) return { ok: true };
1688+
if (await cancelledAfter(CHUNK_LATENCY_MS)) return { state: 'cancelled' };
16431689
this.emit(session.sessionId, {
16441690
type: 'agent-message-chunk',
16451691
messageId,
@@ -1657,7 +1703,7 @@ export class DevMockHost {
16571703
this.emit(session.sessionId, { type: 'stop', stopReason: 'end_turn' });
16581704
session.status = 'idle';
16591705
this.emit(session.sessionId, { type: 'status', status: 'idle' });
1660-
return { ok: true };
1706+
return { state: 'completed' };
16611707
}
16621708

16631709
/** Emitted in one burst, not streamed: this transcript exists to be long, not to look live. */
@@ -2391,11 +2437,6 @@ function promptText(content: readonly ContentBlock[]): string {
23912437
.trim();
23922438
}
23932439

2394-
function settleTurn(session: MockSession, turn: MockTurn, state: 'completed' | 'failed'): void {
2395-
turn.graph.state = state;
2396-
if (session.runningTurnId === turn.graph.turnId) session.runningTurnId = undefined;
2397-
}
2398-
23992440
/** The turns on the root→leaf path, the way the daemon reads one lineage of the tree. */
24002441
function pathTurnIds(session: MockSession, leafTurnId: TurnId | undefined): Set<TurnId> {
24012442
const onPath = new Set<TurnId>();
@@ -2444,6 +2485,19 @@ function readMockProjection(
24442485
items.push({ type: 'history-unavailable', turnId: entry.turnId });
24452486
}
24462487
}
2488+
// A turn refused before it ran journaled nothing; its lineage's read still carries its host user
2489+
// row, the way the daemon reads one from the turn table — and no placeholder: nothing ran.
2490+
const leaf = session.graphTurns.find((turn) => turn.graph.turnId === leafTurnId);
2491+
if (leaf !== undefined && !session.journal.some((entry) => entry.turnId === leaf.graph.turnId)) {
2492+
items.push({
2493+
turnId: leaf.graph.turnId,
2494+
event: {
2495+
type: 'user-message',
2496+
messageId: userRowMessageId(leaf.graph.turnId),
2497+
content: leaf.readContent ?? leaf.content,
2498+
},
2499+
});
2500+
}
24472501
return items;
24482502
}
24492503

0 commit comments

Comments
 (0)