@@ -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' ;
8385import { 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+
181185interface 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. */
24002441function 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