11import { expect , it } from "@effect/vitest" ;
22import {
3+ AgentId ,
34 CommandId ,
45 MessageId ,
56 NodeId ,
@@ -32,6 +33,7 @@ import * as TestClock from "effect/testing/TestClock";
3233
3334import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts" ;
3435import * as ServerSettings from "../serverSettings.ts" ;
36+ import * as ProjectService from "../project/ProjectService.ts" ;
3537import * as SqlitePersistence from "../persistence/Sqlite.ts" ;
3638import * as CommandReceiptStore from "./CommandReceiptStore.ts" ;
3739import * as Orchestrator from "./Orchestrator.ts" ;
@@ -359,7 +361,8 @@ it.effect("inherits the machine value when the project has no override", () => {
359361} ) ;
360362
361363const layerDispatchChokepointTest =
362- ( settingsLayer : ReturnType < typeof ServerSettings . layerTest > ) => ( dispatched : Array < string > ) =>
364+ ( settingsLayer : ReturnType < typeof ServerSettings . layerTest > ) =>
365+ ( dispatched : Array < string > , agentListCalls : Array < ProjectId > = [ ] , validAgentId ?: AgentId ) =>
363366 ThreadManagementService . layer . pipe (
364367 Layer . provide ( Layer . mock ( ProviderSessionManager . ProviderSessionManagerV2 ) ( { } ) ) ,
365368 Layer . provide (
@@ -383,6 +386,17 @@ const layerDispatchChokepointTest =
383386 } ) ,
384387 } ) ,
385388 ) ,
389+ Layer . provideMerge (
390+ Layer . mock ( ProjectService . ProjectService ) ( {
391+ listAgents : ( { projectId } ) =>
392+ Effect . sync ( ( ) => {
393+ agentListCalls . push ( projectId ) ;
394+ return validAgentId === undefined
395+ ? [ ]
396+ : [ { id : validAgentId , name : "Selected agent" , description : "Test agent." } ] ;
397+ } ) ,
398+ } ) ,
399+ ) ,
386400 Layer . provide ( settingsLayer ) ,
387401 Layer . provideMerge ( layerReceiptsTest ) ,
388402 ) ;
@@ -476,6 +490,133 @@ it.effect(
476490 } ,
477491) ;
478492
493+ it . effect ( "rejects a direct thread.create with an unknown custom agent before dispatch" , ( ) => {
494+ const projectId = ProjectId . make ( "project:thread-management:unknown-agent" ) ;
495+ const threadId = ThreadId . make ( "thread:thread-management:unknown-agent" ) ;
496+ const commandId = CommandId . make ( "command:thread-management:unknown-agent" ) ;
497+ const dispatched : Array < string > = [ ] ;
498+ const agentListCalls : Array < ProjectId > = [ ] ;
499+ const layerTest = layerDispatchChokepointTest ( ServerSettings . layerTest ( ) ) (
500+ dispatched ,
501+ agentListCalls ,
502+ ) ;
503+
504+ return Effect . gen ( function * ( ) {
505+ const threads = yield * ThreadManagementService . ThreadManagementService ;
506+ const error = yield * Effect . flip (
507+ threads . dispatch ( {
508+ type : "thread.create" ,
509+ commandId,
510+ threadId,
511+ projectId,
512+ title : "Unknown agent" ,
513+ modelSelection : {
514+ instanceId : ProviderInstanceId . make ( "codex" ) ,
515+ model : "test" ,
516+ } ,
517+ agentId : AgentId . make ( "missing-agent" ) ,
518+ runtimeMode : "full-access" ,
519+ interactionMode : "default" ,
520+ branch : null ,
521+ worktreePath : null ,
522+ createdBy : "user" ,
523+ creationSource : "web" ,
524+ } ) ,
525+ ) ;
526+
527+ expect ( error ) . toBeInstanceOf ( Orchestrator . OrchestratorCommandRejectedError ) ;
528+ expect ( dispatched ) . toEqual ( [ ] ) ;
529+ expect ( agentListCalls ) . toEqual ( [ projectId ] ) ;
530+ } ) . pipe ( Effect . provide ( layerTest ) ) ;
531+ } ) ;
532+
533+ it . effect ( "accepts a direct thread.create when its custom agent exists" , ( ) => {
534+ const projectId = ProjectId . make ( "project:thread-management:known-agent" ) ;
535+ const threadId = ThreadId . make ( "thread:thread-management:known-agent" ) ;
536+ const commandId = CommandId . make ( "command:thread-management:known-agent" ) ;
537+ const agentId = AgentId . make ( "selected-agent" ) ;
538+ const dispatched : Array < string > = [ ] ;
539+ const agentListCalls : Array < ProjectId > = [ ] ;
540+ const layerTest = layerDispatchChokepointTest ( ServerSettings . layerTest ( ) ) (
541+ dispatched ,
542+ agentListCalls ,
543+ agentId ,
544+ ) ;
545+
546+ return Effect . gen ( function * ( ) {
547+ const threads = yield * ThreadManagementService . ThreadManagementService ;
548+ yield * threads . dispatch ( {
549+ type : "thread.create" ,
550+ commandId,
551+ threadId,
552+ projectId,
553+ title : "Known agent" ,
554+ modelSelection : {
555+ instanceId : ProviderInstanceId . make ( "codex" ) ,
556+ model : "test" ,
557+ } ,
558+ agentId,
559+ runtimeMode : "full-access" ,
560+ interactionMode : "default" ,
561+ branch : null ,
562+ worktreePath : null ,
563+ createdBy : "user" ,
564+ creationSource : "web" ,
565+ } ) ;
566+
567+ expect ( dispatched ) . toEqual ( [ "thread.create" ] ) ;
568+ expect ( agentListCalls ) . toEqual ( [ projectId ] ) ;
569+ } ) . pipe ( Effect . provide ( layerTest ) ) ;
570+ } ) ;
571+
572+ it . effect ( "replays an accepted thread.create without revalidating its custom agent" , ( ) => {
573+ const projectId = ProjectId . make ( "project:thread-management:agent-replay" ) ;
574+ const threadId = ThreadId . make ( "thread:thread-management:agent-replay" ) ;
575+ const commandId = CommandId . make ( "command:thread-management:agent-replay" ) ;
576+ const dispatched : Array < string > = [ ] ;
577+ const agentListCalls : Array < ProjectId > = [ ] ;
578+ const layerTest = layerDispatchChokepointTest ( ServerSettings . layerTest ( ) ) (
579+ dispatched ,
580+ agentListCalls ,
581+ ) ;
582+
583+ return Effect . gen ( function * ( ) {
584+ const receipts = yield * CommandReceiptStore . CommandReceiptStoreV2 ;
585+ yield * receipts . insertIfAbsent ( {
586+ commandId,
587+ threadId,
588+ commandType : "thread.create" ,
589+ acceptedAt : yield * DateTime . now ,
590+ resultSequence : 1 ,
591+ status : "accepted" ,
592+ error : null ,
593+ } ) ;
594+
595+ const threads = yield * ThreadManagementService . ThreadManagementService ;
596+ yield * threads . dispatch ( {
597+ type : "thread.create" ,
598+ commandId,
599+ threadId,
600+ projectId,
601+ title : "Accepted agent replay" ,
602+ modelSelection : {
603+ instanceId : ProviderInstanceId . make ( "codex" ) ,
604+ model : "test" ,
605+ } ,
606+ agentId : AgentId . make ( "removed-agent" ) ,
607+ runtimeMode : "full-access" ,
608+ interactionMode : "default" ,
609+ branch : null ,
610+ worktreePath : null ,
611+ createdBy : "user" ,
612+ creationSource : "web" ,
613+ } ) ;
614+
615+ expect ( dispatched ) . toEqual ( [ "thread.create" ] ) ;
616+ expect ( agentListCalls ) . toEqual ( [ ] ) ;
617+ } ) . pipe ( Effect . provide ( layerTest ) ) ;
618+ } ) ;
619+
479620it . effect ( "classifies projection infrastructure failures separately from a missing thread" , ( ) => {
480621 const projectId = ProjectId . make ( "project:thread-management:projection-failure" ) ;
481622 const threadId = ThreadId . make ( "thread:thread-management:projection-failure" ) ;
0 commit comments