From f6df879e5359d428faa0404e552cba546abd46e2 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Mon, 5 Oct 2026 21:24:51 +0200 Subject: [PATCH] fix(agenstra): ticket automation reliability migrate the ticket automation to a more reliable logic to avoid runs from failing --- ...000000_RenameAutomationCompletionSignal.ts | 19 ++ ..._AddPreferredModelsForChatAndAutomation.ts | 29 +++ .../processors/controller-jobs.processor.ts | 8 +- .../src/i18n/messages.de.xlf | 28 ++- .../src/i18n/messages.xlf | 20 +- .../ai-agents/agent-client-protocol.md | 2 +- docs/agenstra/features/ticket-automation.md | 15 +- graph/graph.json | 7 +- .../docs/autonomous-ticket-decision-tree.mmd | 6 +- .../docs/sequence-autonomous-ticket.mmd | 12 +- .../spec/openapi.yaml | 29 ++- .../ticket-automation-response.dto.ts | 1 + .../ticket-automation-run-response.dto.ts | 2 +- .../update-ticket-automation.dto.ts | 11 + .../lib/dto/tickets/ticket-response.dto.ts | 1 + .../src/lib/dto/tickets/update-ticket.dto.ts | 11 +- .../ticket-automation-run.entity.spec.ts | 2 +- .../entities/ticket-automation-run.entity.ts | 4 +- .../entities/ticket-automation.entity.spec.ts | 2 + .../lib/entities/ticket-automation.entity.ts | 7 + .../lib/entities/ticket-automation.enums.ts | 3 + .../src/lib/entities/ticket.entity.ts | 4 + .../src/lib/gateways/clients.gateway.ts | 32 ++- ...utonomous-run-orchestrator.service.spec.ts | 96 +++++--- .../autonomous-run-orchestrator.service.ts | 108 +++++++-- .../services/knowledge-tree.service.spec.ts | 126 ++++++++++- .../lib/services/knowledge-tree.service.ts | 104 ++++++++- ...encode-config-sync-targets.service.spec.ts | 30 +++ .../opencode-config-sync-targets.service.ts | 67 +++++- .../services/remote-agents-session.service.ts | 121 +++++++++- ...icket-automation-chat-sync.service.spec.ts | 4 +- .../ticket-automation.service.spec.ts | 18 +- .../lib/services/ticket-automation.service.ts | 23 +- .../src/lib/services/tickets.service.ts | 70 ++++++ .../utils/automation-completion.constants.ts | 5 - .../utils/automation-failure-routing.spec.ts | 5 + .../lib/utils/automation-failure-routing.ts | 1 + .../lib/utils/automation-turn-status.spec.ts | 52 +++++ .../src/lib/utils/automation-turn-status.ts | 67 ++++++ .../lib/utils/preferred-model.utils.spec.ts | 24 ++ .../src/lib/utils/preferred-model.utils.ts | 34 +++ .../utils/ticket-board-realtime-mappers.ts | 2 +- .../tickets-prototype-prompt.utils.spec.ts | 28 ++- .../utils/tickets-prototype-prompt.utils.ts | 35 ++- .../constants/chat-session.constants.spec.ts | 23 ++ .../lib/constants/chat-session.constants.ts | 21 ++ .../src/lib/gateways/agents.gateway.spec.ts | 25 ++ .../src/lib/gateways/agents.gateway.ts | 42 +++- .../opencode/opencode-config-sync.service.ts | 30 +++ .../opencode/opencode-event-mapper.spec.ts | 31 +++ .../opencode/opencode-event-mapper.ts | 61 ++++- .../opencode/opencode-runtime.service.ts | 214 +++++++++++++++++- .../providers/opencode/opencode-sdk.types.ts | 13 +- .../opencode/opencode-session.service.spec.ts | 63 ++++++ .../opencode/opencode-session.service.ts | 12 + .../lib/services/agents-vcs.service.spec.ts | 122 ++++++++-- .../src/lib/services/agents-vcs.service.ts | 142 +++++++++++- .../src/lib/services/tickets.service.spec.ts | 2 +- .../ticket-automation.effects.spec.ts | 1 + .../ticket-automation.reducer.spec.ts | 3 +- .../ticket-automation.types.ts | 4 +- .../lib/state/tickets/tickets.reducer.spec.ts | 1 + .../src/lib/state/tickets/tickets.types.ts | 3 + .../ticket-automation-chat-run-mapper.ts | 2 +- .../chat/chat-automation-card-merge.spec.ts | 2 +- .../tickets/ticket-automation-run-labels.ts | 4 +- .../lib/tickets/tickets-board.component.html | 54 +++++ .../lib/tickets/tickets-board.component.ts | 116 +++++++++- ...enstra-shared-util-opencode-config.spec.ts | 17 +- .../agenstra-shared-util-opencode-config.ts | 1 + .../src/lib/automation-platform.spec.ts | 46 ++++ .../src/lib/automation-platform.ts | 174 ++++++++++++++ .../src/lib/materialize.ts | 20 +- .../notification-indicator.component.scss | 3 - .../notification-indicator.component.ts | 12 +- .../notification-indicator.stories.ts | 2 +- 76 files changed, 2356 insertions(+), 185 deletions(-) create mode 100644 apps/agenstra/backend-agent-controller/src/migrations/1775400000000_RenameAutomationCompletionSignal.ts create mode 100644 apps/agenstra/backend-agent-controller/src/migrations/1775500000000_AddPreferredModelsForChatAndAutomation.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.spec.ts delete mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-completion.constants.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-turn-status.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-turn-status.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/preferred-model.utils.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/preferred-model.utils.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.spec.ts create mode 100644 libs/domains/agenstra/shared/util-opencode-config/src/lib/automation-platform.spec.ts create mode 100644 libs/domains/agenstra/shared/util-opencode-config/src/lib/automation-platform.ts diff --git a/apps/agenstra/backend-agent-controller/src/migrations/1775400000000_RenameAutomationCompletionSignal.ts b/apps/agenstra/backend-agent-controller/src/migrations/1775400000000_RenameAutomationCompletionSignal.ts new file mode 100644 index 000000000..70c21e9df --- /dev/null +++ b/apps/agenstra/backend-agent-controller/src/migrations/1775400000000_RenameAutomationCompletionSignal.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameAutomationCompletionSignal1775400000000 implements MigrationInterface { + name = 'RenameAutomationCompletionSignal1775400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "ticket_automation_run" + RENAME COLUMN "completion_marker_seen" TO "completion_signal_seen" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "ticket_automation_run" + RENAME COLUMN "completion_signal_seen" TO "completion_marker_seen" + `); + } +} diff --git a/apps/agenstra/backend-agent-controller/src/migrations/1775500000000_AddPreferredModelsForChatAndAutomation.ts b/apps/agenstra/backend-agent-controller/src/migrations/1775500000000_AddPreferredModelsForChatAndAutomation.ts new file mode 100644 index 000000000..fb2702b29 --- /dev/null +++ b/apps/agenstra/backend-agent-controller/src/migrations/1775500000000_AddPreferredModelsForChatAndAutomation.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Persist explicit OpenCode model (`provider/model`) for ticket chat/AI and automation. + * OpenCode no longer has an auto model; callers must pass a concrete selection. + */ +export class AddPreferredModelsForChatAndAutomation1775500000000 implements MigrationInterface { + name = 'AddPreferredModelsForChatAndAutomation1775500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "tickets" + ADD COLUMN IF NOT EXISTS "preferred_chat_model" varchar(256) NULL + `); + await queryRunner.query(` + ALTER TABLE "ticket_automation" + ADD COLUMN IF NOT EXISTS "preferred_model" varchar(256) NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "ticket_automation" DROP COLUMN IF EXISTS "preferred_model" + `); + await queryRunner.query(` + ALTER TABLE "tickets" DROP COLUMN IF EXISTS "preferred_chat_model" + `); + } +} diff --git a/apps/agenstra/backend-agent-controller/src/queue/processors/controller-jobs.processor.ts b/apps/agenstra/backend-agent-controller/src/queue/processors/controller-jobs.processor.ts index e117d57e9..d77b71bed 100644 --- a/apps/agenstra/backend-agent-controller/src/queue/processors/controller-jobs.processor.ts +++ b/apps/agenstra/backend-agent-controller/src/queue/processors/controller-jobs.processor.ts @@ -331,7 +331,13 @@ export class ControllerJobsProcessor extends WorkerHost { } private async runOpencodeConfigSyncCoordinator(): Promise { - const targetIds = await this.opencodeConfigSyncTargets.findPendingTargetIds(getOpencodeConfigSyncBatchSize()); + const batchSize = getOpencodeConfigSyncBatchSize(); + // Prefer already-pending/failed targets so reconcile rollouts cannot starve retries. + const pending = await this.opencodeConfigSyncTargets.findPendingTargetIds(batchSize); + const remaining = Math.max(0, batchSize - pending.length); + const reconciled = + remaining > 0 ? await this.opencodeConfigSyncTargets.reconcileOutdatedSyncedTargets(remaining) : []; + const targetIds = [...new Set([...pending, ...reconciled])].slice(0, batchSize); for (const targetId of targetIds) { await enqueueUnitJob({ diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf index eda926e9c..e3c7a74ba 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf @@ -3127,13 +3127,17 @@ Agent provider error Agent-Provider-Fehler + + No completion status + Kein Abschlussstatus + No completion marker Kein Abschluss-Marker - Completion marker without verify profile - Abschluss-Marker ohne Verifizierungsprofil + Completion without verify profile + Abschluss ohne Verify-Profil Verify command failed @@ -3307,10 +3311,30 @@ Agent for chat / AI Agent für Chat / KI + + Model for chat / AI + Modell für Chat / KI + + + {{ ticketAiModelsLoading() ? 'Loading models…' : 'Select agent to load models…' }} + {{ ticketAiModelsLoading() ? 'Modelle werden geladen…' : 'Agent wählen, um Modelle zu laden…' }} + Select agent… Agent wählen… + + Model for automation + Modell für Automatisierung + + + Required when automation is enabled. OpenCode has no auto model selection. + Erforderlich, wenn die Automatisierung aktiviert ist. OpenCode hat keine Auto-Modellauswahl. + + + {{ ticketAiModelsLoading() ? 'Loading models…' : 'Select an agent to load models…' }} + {{ ticketAiModelsLoading() ? 'Modelle werden geladen…' : 'Agent wählen, um Modelle zu laden…' }} + Acceptance % Agent provider error + + No completion status + No completion marker - Completion marker without verify profile + Completion without verify profile Verify command failed @@ -3088,9 +3091,24 @@ Agent for chat / AI + + Model for chat / AI + + + {{ ticketAiModelsLoading() ? 'Loading models…' : 'Select agent to load models…' }} + Select agent… + + Model for automation + + + Required when automation is enabled. OpenCode has no auto model selection. + + + {{ ticketAiModelsLoading() ? 'Loading models…' : 'Select an agent to load models…' }} + Acceptance % failProvider["Fail run: AGENT_PROVIDER_ERROR"] workspacePrep -- "Yes" --> loopStart["Agent loop (maxIterations)"] - loopStart --> markerSeen{"Completion marker seen?"} - markerSeen -- "No, iterations exhausted" --> failNoMarker["Fail run: AGENT_NO_COMPLETION_MARKER (timed_out)"] - markerSeen -- "Yes" --> verifierConfigured{"Verifier commands configured?"} + loopStart --> signalSeen{"Structured status complete?"} + signalSeen -- "No, iterations exhausted" --> failNoSignal["Fail run: AGENT_NO_COMPLETION_STATUS (timed_out)"] + signalSeen -- "Yes" --> verifierConfigured{"Verifier commands configured?"} verifierConfigured -- "No" --> verifySkipped["Skip verify"] verifierConfigured -- "Yes" --> verifyResult{"All verifier commands exit 0?"} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-autonomous-ticket.mmd b/libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-autonomous-ticket.mmd index 894996bda..99dea648d 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-autonomous-ticket.mmd +++ b/libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-autonomous-ticket.mmd @@ -21,15 +21,15 @@ sequenceDiagram end Note over Orch,Vcs: Default strategy reuses `automation/ticket-{ticketId}`; `new_per_run` or one-shot force uses ephemeral `automation/{runPrefix}`. loop maxIterations - Orch->>Chat: sendChatSync(sync, ephemeral, contextInjection) + Orch->>Chat: sendChatSync(sync, ephemeral, expectAutomationTurnStatus) Chat->>Mgr: chat - Mgr->>Prov: sendMessage(continue, resumeSessionSuffix) - Prov-->>Mgr: NDJSON / text - Mgr-->>Chat: chatMessage - Chat-->>Orch: assistant text + Mgr->>Prov: sendMessage(continue, resumeSessionSuffix, format json_schema) + Prov-->>Mgr: structured status + text + Mgr-->>Chat: chatMessage (final result) + Chat-->>Orch: RemoteChatSyncResult (text, turnStatus) Orch->>DB: append run step end - Note over Orch,Chat: contextInjection carries includeWorkspace, environmentIds, and autoEnrichmentEnabled. + Note over Orch,Chat: Loop exits only when turnStatus is complete. Automation sessions use allow-all + auto-reply. Orch->>Vcs: runVerifierCommands Orch->>Vcs: getStatus; if dirty: stage all Orch->>Chat: sendChatSync (conventional commit subject; autonomous_ticket_commit_message) diff --git a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml index 934fc4dac..b04e9dedb 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml @@ -7660,6 +7660,11 @@ components: type: string format: uuid description: Preferred workspace agent for chat/AI when viewing this ticket (agent-manager agent id). + preferredChatModel: + type: string + maxLength: 256 + nullable: true + description: Preferred OpenCode provider/model for chat/AI on this ticket (e.g. anthropic/claude-sonnet-4). automationEligible: type: boolean description: Whether autonomous prototyping is enabled for this ticket (ticket_automation.eligible). @@ -7745,6 +7750,11 @@ components: type: string format: uuid description: Persist preferred workspace agent for chat/AI; send null to clear. + preferredChatModel: + type: string + maxLength: 256 + nullable: true + description: Persist preferred OpenCode provider/model for chat/AI; send null to clear. TicketCommentResponseDto: type: object required: [id, ticketId, body, createdAt] @@ -7829,6 +7839,11 @@ components: items: type: string format: uuid + preferredModel: + type: string + maxLength: 256 + nullable: true + description: OpenCode provider/model for autonomous turns; required when eligible is true. includeWorkspaceContext: type: boolean contextEnvironmentIds: @@ -7862,6 +7877,7 @@ components: - ticketId - eligible - allowedAgentIds + - preferredModel - includeWorkspaceContext - contextEnvironmentIds - autoEnrichmentEnabled @@ -7888,6 +7904,11 @@ components: items: type: string format: uuid + preferredModel: + type: string + maxLength: 256 + nullable: true + description: OpenCode provider/model used for autonomous turns on this ticket. includeWorkspaceContext: type: boolean contextEnvironmentIds: @@ -7978,7 +7999,7 @@ components: - finishedAt - updatedAt - iterationCount - - completionMarkerSeen + - completionSignalSeen - verificationPassed - failureCode - summary @@ -8021,7 +8042,7 @@ components: format: date-time iterationCount: type: integer - completionMarkerSeen: + completionSignalSeen: type: boolean verificationPassed: type: boolean @@ -8030,8 +8051,8 @@ components: description: > Present when a run ends unsuccessfully. Includes values such as verify_command_failed, commit_failed (git commit after successful verification), push_failed (git push after that commit), - agent_no_completion_marker, marker_without_verify, agent_provider_error, vcs_dirty_workspace, - and others defined by the controller. + agent_no_completion_status, agent_no_completion_marker (legacy), marker_without_verify, agent_provider_error, + vcs_dirty_workspace, and others defined by the controller. summary: type: object additionalProperties: true diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-response.dto.ts index 895e7336c..79d5c18f7 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-response.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-response.dto.ts @@ -4,6 +4,7 @@ export class TicketAutomationResponseDto { ticketId!: string; eligible!: boolean; allowedAgentIds!: string[]; + preferredModel!: string | null; includeWorkspaceContext!: boolean; contextEnvironmentIds!: string[]; autoEnrichmentEnabled!: boolean; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-run-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-run-response.dto.ts index efbec5613..83966de3d 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-run-response.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-run-response.dto.ts @@ -25,7 +25,7 @@ export class TicketAutomationRunResponseDto { finishedAt!: Date | null; updatedAt!: Date; iterationCount!: number; - completionMarkerSeen!: boolean; + completionSignalSeen!: boolean; verificationPassed!: boolean | null; failureCode!: string | null; summary!: Record | null; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/update-ticket-automation.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/update-ticket-automation.dto.ts index b13c2c9f1..c30b6d94e 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/update-ticket-automation.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/ticket-automation/update-ticket-automation.dto.ts @@ -7,11 +7,14 @@ import { IsOptional, IsString, IsUUID, + Matches, MaxLength, + ValidateIf, ValidateNested, } from 'class-validator'; import { TICKET_AUTOMATION_BRANCH_STRATEGIES } from '../../utils/ticket-automation-branch.constants'; +import { PREFERRED_MODEL_PATTERN } from '../../utils/preferred-model.utils'; /** Single verifier command; mirrors {@link parseAndValidateVerifierProfile} bounds. */ export class VerifierCommandEntryDto { @@ -43,6 +46,14 @@ export class UpdateTicketAutomationDto { @IsUUID('4', { each: true }) allowedAgentIds?: string[]; + /** OpenCode `provider/model` for autonomous turns; set `null` to clear. Required when eligible. */ + @IsOptional() + @ValidateIf((_, v) => v !== null && v !== undefined) + @IsString() + @MaxLength(256) + @Matches(PREFERRED_MODEL_PATTERN) + preferredModel?: string | null; + @IsOptional() @IsBoolean() includeWorkspaceContext?: boolean; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts index 67992bb30..281825496 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/ticket-response.dto.ts @@ -35,6 +35,7 @@ export class TicketResponseDto { createdByEmail?: string | null; /** Preferred workspace agent for chat/AI when viewing this ticket. */ preferredChatAgentId?: string | null; + preferredChatModel?: string | null; /** True when autonomous prototyping is enabled for this ticket (`ticket_automation.eligible`). */ automationEligible!: boolean; createdAt!: Date; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/update-ticket.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/update-ticket.dto.ts index dc2bb69f1..97c059d78 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/update-ticket.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/tickets/update-ticket.dto.ts @@ -1,6 +1,7 @@ -import { IsEnum, IsOptional, IsString, IsUUID, MaxLength, ValidateIf } from 'class-validator'; +import { IsEnum, IsOptional, IsString, IsUUID, Matches, MaxLength, ValidateIf } from 'class-validator'; import { TicketPriority, TicketStatus } from '../../entities/ticket.enums'; +import { PREFERRED_MODEL_PATTERN } from '../../utils/preferred-model.utils'; export class UpdateTicketDto { @IsOptional() @@ -33,4 +34,12 @@ export class UpdateTicketDto { @ValidateIf((_, v) => v !== null && v !== undefined) @IsUUID('4') preferredChatAgentId?: string | null; + + /** Persist OpenCode `provider/model` for chat/AI on this ticket; set `null` to clear. */ + @IsOptional() + @ValidateIf((_, v) => v !== null && v !== undefined) + @IsString() + @MaxLength(256) + @Matches(PREFERRED_MODEL_PATTERN) + preferredChatModel?: string | null; } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.spec.ts index 924259941..652c6735c 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.spec.ts @@ -25,7 +25,7 @@ describe('TicketAutomationRunEntity', () => { entity.finishedAt = null; entity.updatedAt = new Date(); entity.iterationCount = 3; - entity.completionMarkerSeen = false; + entity.completionSignalSeen = false; entity.verificationPassed = null; entity.failureCode = null; entity.summary = { note: 'ok' }; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.ts index 95c48a9d0..96ee4ce85 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation-run.entity.ts @@ -64,8 +64,8 @@ export class TicketAutomationRunEntity { @Column({ type: 'int', name: 'iteration_count', default: 0 }) iterationCount!: number; - @Column({ type: 'boolean', name: 'completion_marker_seen', default: false }) - completionMarkerSeen!: boolean; + @Column({ type: 'boolean', name: 'completion_signal_seen', default: false }) + completionSignalSeen!: boolean; @Column({ type: 'boolean', name: 'verification_passed', nullable: true }) verificationPassed?: boolean | null; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.spec.ts index 4c4fdbbba..bc7056443 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.spec.ts @@ -16,6 +16,7 @@ describe('TicketAutomationEntity', () => { entity.ticketId = 'ticket-uuid'; entity.eligible = true; entity.allowedAgentIds = ['agent-uuid']; + entity.preferredModel = 'opencode/test-model'; entity.includeWorkspaceContext = true; entity.contextEnvironmentIds = ['context-agent-uuid']; entity.autoEnrichmentEnabled = true; @@ -33,6 +34,7 @@ describe('TicketAutomationEntity', () => { expect(entity.ticketId).toBe('ticket-uuid'); expect(entity.eligible).toBe(true); expect(entity.allowedAgentIds).toEqual(['agent-uuid']); + expect(entity.preferredModel).toBe('opencode/test-model'); expect(entity.includeWorkspaceContext).toBe(true); expect(entity.contextEnvironmentIds).toEqual(['context-agent-uuid']); expect(entity.autoEnrichmentEnabled).toBe(true); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.ts index 720949f85..100d79b9e 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.ts @@ -29,6 +29,13 @@ export class TicketAutomationEntity { @Column({ type: 'jsonb', name: 'allowed_agent_ids', default: () => "'[]'" }) allowedAgentIds!: string[]; + /** + * OpenCode `provider/model` used for autonomous chat turns on this ticket. + * Required when eligible is true (validated on write); OpenCode has no auto model. + */ + @Column({ type: 'varchar', length: 256, name: 'preferred_model', nullable: true }) + preferredModel?: string | null; + /** * Adds shared workspace context (`/opt/workspace`) for autonomous prompt enrichment when true. */ diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.enums.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.enums.ts index dfe990225..7b2a8bf06 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.enums.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket-automation.enums.ts @@ -30,7 +30,10 @@ export enum TicketAutomationFailureCode { VCS_DIRTY_WORKSPACE = 'vcs_dirty_workspace', VCS_BRANCH_EXISTS = 'vcs_branch_exists', AGENT_PROVIDER_ERROR = 'agent_provider_error', + AGENT_NO_COMPLETION_STATUS = 'agent_no_completion_status', + /** @deprecated Retained for historical rows; prefer AGENT_NO_COMPLETION_STATUS. */ AGENT_NO_COMPLETION_MARKER = 'agent_no_completion_marker', + /** @deprecated Unused by current orchestrator. */ MARKER_WITHOUT_VERIFY = 'marker_without_verify', VERIFY_COMMAND_FAILED = 'verify_command_failed', /** Git commit after successful verification failed (e.g. empty index, hook, or I/O). */ diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket.entity.ts index 252c1ae60..0c6d3575f 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/ticket.entity.ts @@ -71,6 +71,10 @@ export class TicketEntity { @Column({ type: 'uuid', nullable: true, name: 'preferred_chat_agent_id' }) preferredChatAgentId?: string | null; + /** OpenCode `provider/model` the user last chose for chat/AI on this ticket. */ + @Column({ type: 'varchar', length: 256, nullable: true, name: 'preferred_chat_model' }) + preferredChatModel?: string | null; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt!: Date; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts index 129b76821..0df9a1669 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts @@ -1204,9 +1204,10 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat } if (event === 'chat' && agentId) { + const sanitizedPayload = this.sanitizeForwardedChatPayload(payloadWithContext); const message = - (payloadWithContext as { message?: string; contextInjection?: ContextInjectionPayload })?.message ?? ''; - const chatId = (payloadWithContext as { chatId?: string })?.chatId; + (sanitizedPayload as { message?: string; contextInjection?: ContextInjectionPayload })?.message ?? ''; + const chatId = (sanitizedPayload as { chatId?: string })?.chatId; const wordCount = message.trim().split(/\s+/).filter(Boolean).length; const charCount = message.length; const userInfo = (socket as Socket & { data?: { userInfo?: { userId?: string } } }).data?.userInfo; @@ -1224,6 +1225,11 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat }); this.lastAgentIdBySocket.set(socket.id, agentId); this.lastChatMessageBySocket.set(socket.id, message); + remote.emit(event, sanitizedPayload); + // SECURITY: Acknowledgement sent only to the initiating socket + socket.emit('forwardAck', { received: true, event }); + + return; } else if (event === 'enhanceChat' && agentId) { const message = (payloadWithContext as { message?: string })?.message ?? ''; const wordCount = message.trim().split(/\s+/).filter(Boolean).length; @@ -1273,6 +1279,28 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat } } + /** + * Interactive console forwards must never carry ticket-automation allow-all suffixes + * or the orchestrator trust marker. + */ + private sanitizeForwardedChatPayload(payload: unknown): unknown { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return payload; + } + + const next = { ...(payload as Record) }; + const suffix = typeof next['resumeSessionSuffix'] === 'string' ? next['resumeSessionSuffix'] : undefined; + const ticketAutomationSuffixes = new Set(['-ticket-auto-pre', '-ticket-auto-loop', '-ticket-auto-commit-msg']); + + if (suffix && ticketAutomationSuffixes.has(suffix)) { + delete next['resumeSessionSuffix']; + } + + delete next['unattendedAutomation']; + + return next; + } + private async resolveWorkspaceAutoEnrichSettings(clientId: string): Promise<{ enabledGlobal?: boolean; vectorMaxCosineDistance?: number; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.spec.ts index 967b1101a..f3eca3e56 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.spec.ts @@ -10,7 +10,6 @@ import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; import { TicketAutomationFailureCode } from '../entities/ticket-automation.enums'; import { TicketEntity } from '../entities/ticket.entity'; import { TicketActionType, TicketStatus } from '../entities/ticket.enums'; -import { AGENSTRA_AUTOMATION_COMPLETE } from '../utils/automation-completion.constants'; import { ephemeralAutomationBranchNameForRun, stableAutomationBranchNameForTicket, @@ -18,6 +17,7 @@ import { import { AutonomousRunOrchestratorService } from './autonomous-run-orchestrator.service'; import { ClientAgentVcsProxyService } from './client-agent-vcs-proxy.service'; +import { KnowledgeTreeService } from './knowledge-tree.service'; import { RemoteAgentsSessionService } from './remote-agents-session.service'; import { TicketAutomationChatSyncService } from './ticket-automation-chat-sync.service'; import { TicketAutomationService } from './ticket-automation.service'; @@ -28,7 +28,23 @@ function realtimeSideProviders(ticketIdForBoard: string) { return [ { provide: TicketBoardRealtimeService, useValue: { emitToClient: jest.fn() } }, { provide: TicketAutomationChatSyncService, useValue: { emitLiveRunUpdateFromEntity: jest.fn() } }, - { provide: TicketsService, useValue: { emitBoardTicketSnapshotInternal: jest.fn().mockResolvedValue(undefined) } }, + { + provide: TicketsService, + useValue: { + emitBoardTicketSnapshotInternal: jest.fn().mockResolvedValue(undefined), + buildAutomationTicketPromptBody: jest + .fn() + .mockResolvedValue('- [ticket] Test ticket (todo, medium)\n Content:\n Do the work\n'), + listAutomationRelationSourceTicketIds: jest.fn().mockResolvedValue([ticketIdForBoard]), + listAutomationPromptTicketLongShas: jest.fn().mockResolvedValue([]), + }, + }, + { + provide: KnowledgeTreeService, + useValue: { + collectAutomationRelationPromptSections: jest.fn().mockResolvedValue([]), + }, + }, { provide: TicketAutomationService, useValue: { @@ -36,6 +52,7 @@ function realtimeSideProviders(ticketIdForBoard: string) { ticketId: ticketIdForBoard, eligible: false, allowedAgentIds: [], + preferredModel: 'opencode/test-model', verifierProfile: null, requiresApproval: false, approvedAt: null, @@ -121,7 +138,7 @@ describe('AutonomousRunOrchestratorService', () => { await module.close(); }); - it('processBatch completes a run when agent returns completion marker and verifier passes', async () => { + it('processBatch completes a run when agent returns structured complete status and verifier passes', async () => { const ticketRepo = { manager: { query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), @@ -138,6 +155,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, }), update: jest.fn().mockResolvedValue({ affected: 1 }), @@ -169,8 +187,8 @@ describe('AutonomousRunOrchestratorService', () => { const remoteChat = { sendChatSync: jest .fn() - .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) - .mockResolvedValueOnce('feat(automation): implement ticket'), + .mockResolvedValueOnce({ text: 'Done.', turnStatus: 'complete' }) + .mockResolvedValueOnce({ text: 'feat(automation): implement ticket' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -258,6 +276,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, approvedAt, }), @@ -290,8 +309,8 @@ describe('AutonomousRunOrchestratorService', () => { const remoteChat = { sendChatSync: jest .fn() - .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) - .mockResolvedValueOnce('feat(automation): implement ticket'), + .mockResolvedValueOnce({ text: 'Done.', turnStatus: 'complete' }) + .mockResolvedValueOnce({ text: 'feat(automation): implement ticket' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -352,6 +371,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, }), update: jest.fn().mockResolvedValue({ affected: 1 }), @@ -383,8 +403,8 @@ describe('AutonomousRunOrchestratorService', () => { const remoteChat = { sendChatSync: jest .fn() - .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) - .mockResolvedValueOnce('feat(automation): implement ticket'), + .mockResolvedValueOnce({ text: 'Done.', turnStatus: 'complete' }) + .mockResolvedValueOnce({ text: 'feat(automation): implement ticket' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -437,6 +457,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: ['99999999-9999-4999-8999-999999999999'], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, }), }; @@ -476,7 +497,7 @@ describe('AutonomousRunOrchestratorService', () => { await module.close(); }); - it('processBatch fails run with timed_out when agent never emits completion marker', async () => { + it('processBatch fails run with timed_out when agent never emits completion status', async () => { const ticketRepo = { manager: { query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), @@ -488,6 +509,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, consecutiveFailureCount: 0, }), @@ -524,7 +546,7 @@ describe('AutonomousRunOrchestratorService', () => { }; const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; const remoteChat = { - sendChatSync: jest.fn().mockResolvedValue('still working, no marker yet'), + sendChatSync: jest.fn().mockResolvedValue({ text: 'still working', turnStatus: 'continue' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -556,7 +578,7 @@ describe('AutonomousRunOrchestratorService', () => { expect(activityRepo.save).toHaveBeenCalledWith( expect.objectContaining({ actionType: TicketActionType.AUTOMATION_TIMED_OUT, - payload: { runId, code: TicketAutomationFailureCode.AGENT_NO_COMPLETION_MARKER }, + payload: { runId, code: TicketAutomationFailureCode.AGENT_NO_COMPLETION_STATUS }, }), ); expect(leaseRepo.update).toHaveBeenCalled(); @@ -575,6 +597,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'npm test' }] }, consecutiveFailureCount: 0, }), @@ -611,7 +634,7 @@ describe('AutonomousRunOrchestratorService', () => { }; const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; const remoteChat = { - sendChatSync: jest.fn().mockResolvedValue(`ok\n${AGENSTRA_AUTOMATION_COMPLETE}\n`), + sendChatSync: jest.fn().mockResolvedValue({ text: 'ok', turnStatus: 'complete' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -677,6 +700,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'npm test' }] }, consecutiveFailureCount: 1, approvedAt, @@ -715,7 +739,7 @@ describe('AutonomousRunOrchestratorService', () => { }; const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; const remoteChat = { - sendChatSync: jest.fn().mockResolvedValue(`ok\n${AGENSTRA_AUTOMATION_COMPLETE}\n`), + sendChatSync: jest.fn().mockResolvedValue({ text: 'ok', turnStatus: 'complete' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -769,6 +793,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, consecutiveFailureCount: 0, }), @@ -838,7 +863,7 @@ describe('AutonomousRunOrchestratorService', () => { await module.close(); }); - it('processBatch succeeds run when completion marker is seen but verifier profile has no commands', async () => { + it('processBatch succeeds run when completion status is seen but verifier profile has no commands', async () => { const ticketRepo = { manager: { query: jest.fn().mockResolvedValue([{ ticket_id: ticketId, client_id: clientId, agent_id: agentId }]), @@ -850,6 +875,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [] }, consecutiveFailureCount: 0, }), @@ -886,7 +912,7 @@ describe('AutonomousRunOrchestratorService', () => { }; const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; const remoteChat = { - sendChatSync: jest.fn().mockResolvedValue(`done\n${AGENSTRA_AUTOMATION_COMPLETE}\n`), + sendChatSync: jest.fn().mockResolvedValue({ text: 'done', turnStatus: 'complete' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -950,6 +976,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, }), update: jest.fn().mockResolvedValue({ affected: 1 }), @@ -979,9 +1006,9 @@ describe('AutonomousRunOrchestratorService', () => { const remoteChat = { sendChatSync: jest .fn() - .mockResolvedValueOnce('pre-improve done') - .mockResolvedValueOnce(`done\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) - .mockResolvedValueOnce('feat(automation): after pre-improve'), + .mockResolvedValueOnce({ text: 'pre-improve done' }) + .mockResolvedValueOnce({ text: 'done', turnStatus: 'complete' }) + .mockResolvedValueOnce({ text: 'feat(automation): after pre-improve' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -1016,7 +1043,8 @@ describe('AutonomousRunOrchestratorService', () => { expect(remoteChat.sendChatSync).toHaveBeenCalledTimes(3); expect(remoteChat.sendChatSync.mock.calls[0][0].message).toContain('Improve ticket clarity only'); - expect(remoteChat.sendChatSync.mock.calls[1][0].message).toContain('Implement the ticket'); + expect(remoteChat.sendChatSync.mock.calls[1][0].message).toContain('Implement this ticket'); + expect(remoteChat.sendChatSync.mock.calls[1][0].message).toContain('Test ticket'); expect(vcsProxy.push).toHaveBeenCalledWith(clientId, agentId, {}); await module.close(); }); @@ -1038,6 +1066,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, }), update: jest.fn().mockResolvedValue({ affected: 1 }), @@ -1065,7 +1094,7 @@ describe('AutonomousRunOrchestratorService', () => { }; const leaseRepo = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; const remoteChat = { - sendChatSync: jest.fn().mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`), + sendChatSync: jest.fn().mockResolvedValueOnce({ text: 'Done.', turnStatus: 'complete' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -1129,6 +1158,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, consecutiveFailureCount: 0, }), @@ -1167,8 +1197,8 @@ describe('AutonomousRunOrchestratorService', () => { const remoteChat = { sendChatSync: jest .fn() - .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) - .mockResolvedValueOnce('feat(automation): ok'), + .mockResolvedValueOnce({ text: 'Done.', turnStatus: 'complete' }) + .mockResolvedValueOnce({ text: 'feat(automation): ok' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -1228,6 +1258,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, consecutiveFailureCount: 0, }), @@ -1266,8 +1297,8 @@ describe('AutonomousRunOrchestratorService', () => { const remoteChat = { sendChatSync: jest .fn() - .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) - .mockResolvedValueOnce('feat(automation): ok'), + .mockResolvedValueOnce({ text: 'Done.', turnStatus: 'complete' }) + .mockResolvedValueOnce({ text: 'feat(automation): ok' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -1328,6 +1359,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, automationBranchStrategy: 'reuse_per_ticket', forceNewAutomationBranchNextRun: false, @@ -1359,8 +1391,8 @@ describe('AutonomousRunOrchestratorService', () => { const remoteChat = { sendChatSync: jest .fn() - .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) - .mockResolvedValueOnce('feat(automation): implement ticket'), + .mockResolvedValueOnce({ text: 'Done.', turnStatus: 'complete' }) + .mockResolvedValueOnce({ text: 'feat(automation): implement ticket' }), }; const mainOnly = [{ name: 'main', isRemote: false }]; const withStable = [...mainOnly, { name: stable, isRemote: false }]; @@ -1417,6 +1449,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, automationBranchStrategy: 'new_per_run', forceNewAutomationBranchNextRun: false, @@ -1448,8 +1481,8 @@ describe('AutonomousRunOrchestratorService', () => { const remoteChat = { sendChatSync: jest .fn() - .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) - .mockResolvedValueOnce('feat(automation): implement ticket'), + .mockResolvedValueOnce({ text: 'Done.', turnStatus: 'complete' }) + .mockResolvedValueOnce({ text: 'feat(automation): implement ticket' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), @@ -1507,6 +1540,7 @@ describe('AutonomousRunOrchestratorService', () => { findOne: jest.fn().mockResolvedValue({ ticketId, allowedAgentIds: [agentId], + preferredModel: 'opencode/test-model', verifierProfile: { commands: [{ cmd: 'echo ok' }] }, automationBranchStrategy: 'reuse_per_ticket', forceNewAutomationBranchNextRun: true, @@ -1538,8 +1572,8 @@ describe('AutonomousRunOrchestratorService', () => { const remoteChat = { sendChatSync: jest .fn() - .mockResolvedValueOnce(`Done.\n${AGENSTRA_AUTOMATION_COMPLETE}\n`) - .mockResolvedValueOnce('feat(automation): implement ticket'), + .mockResolvedValueOnce({ text: 'Done.', turnStatus: 'complete' }) + .mockResolvedValueOnce({ text: 'feat(automation): implement ticket' }), }; const vcsProxy = { getBranches: jest.fn().mockResolvedValue([{ name: 'main', isRemote: false }]), diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts index 4cb35a603..b40d5eac8 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts @@ -17,7 +17,7 @@ import { } from '../entities/ticket-automation.enums'; import { TicketEntity } from '../entities/ticket.entity'; import { TicketActionType, TicketActorType, TicketStatus } from '../entities/ticket.enums'; -import { AGENSTRA_AUTOMATION_COMPLETE } from '../utils/automation-completion.constants'; +import { AGENSTRA_AUTOMATION_TURN_STATUS } from '../utils/automation-turn-status'; import { routeAutomationFailure } from '../utils/automation-failure-routing'; import { isUsablePartialPrototype } from '../utils/automation-usable-partial'; import { @@ -37,9 +37,13 @@ import { ticketAutomationRunEntityToDto, ticketAutomationRunStepEntityToDto, } from '../utils/ticket-board-realtime-mappers'; -import { buildAutonomousTicketRunPreamble } from '../utils/tickets-prototype-prompt.utils'; +import { + buildAutonomousTicketRunPreamble, + appendRelatedContextSections, +} from '../utils/tickets-prototype-prompt.utils'; import { ClientAgentVcsProxyService } from './client-agent-vcs-proxy.service'; +import { KnowledgeTreeService } from './knowledge-tree.service'; import { RemoteAgentsSessionService } from './remote-agents-session.service'; import { TicketAutomationChatSyncService } from './ticket-automation-chat-sync.service'; import { TicketAutomationService } from './ticket-automation.service'; @@ -80,8 +84,25 @@ export class AutonomousRunOrchestratorService { private readonly ticketAutomationChatSync: TicketAutomationChatSyncService, private readonly ticketsService: TicketsService, private readonly ticketAutomationService: TicketAutomationService, + private readonly knowledgeTreeService: KnowledgeTreeService, ) {} + /** + * Ticket tree (parents + nested subtasks) plus relation-linked knowledge/tickets at the same scope depth. + */ + private async buildAutomationRunPrompt(ticket: TicketEntity): Promise { + const ticketPrompt = await this.ticketsService.buildAutomationTicketPromptBody(ticket); + const ticketIds = await this.ticketsService.listAutomationRelationSourceTicketIds(ticket); + const excludeShas = await this.ticketsService.listAutomationPromptTicketLongShas(ticket); + const relationSections = await this.knowledgeTreeService.collectAutomationRelationPromptSections( + ticket.clientId, + ticketIds, + excludeShas, + ); + + return appendRelatedContextSections(ticketPrompt, relationSections); + } + async processBatch(batchSize: number): Promise { const candidates = await this.findCandidates(batchSize); @@ -113,6 +134,7 @@ export class AutonomousRunOrchestratorService { INNER JOIN ticket_automation ta ON ta.ticket_id = t.id INNER JOIN client_agent_autonomy caa ON caa.client_id = t.client_id AND caa.enabled = true WHERE ta.eligible = true + AND NULLIF(BTRIM(COALESCE(ta.preferred_model, t.preferred_chat_model, '')), '') IS NOT NULL AND t.status IN ('todo', 'in_progress') AND ( COALESCE(jsonb_array_length(COALESCE(ta.allowed_agent_ids, '[]'::jsonb)), 0) = 0 @@ -156,6 +178,16 @@ export class AutonomousRunOrchestratorService { return; } + const preferredModel = (automation.preferredModel ?? ticket.preferredChatModel ?? '').trim(); + + if (!preferredModel) { + this.logger.warn( + `Orchestrator skip ticket ${c.ticket_id}: preferredModel is required (OpenCode has no auto model)`, + ); + + return; + } + const allowedAgentIds = automation.allowedAgentIds ?? []; if (allowedAgentIds.length > 0 && !allowedAgentIds.includes(c.agent_id)) { @@ -172,7 +204,7 @@ export class AutonomousRunOrchestratorService { await this.emitRunSummaryNow(ticket.clientId, run.id); try { - await this.executeRunWorkflow(run, ticket, automation, autonomy, c.agent_id); + await this.executeRunWorkflow(run, ticket, automation, autonomy, c.agent_id, preferredModel); } catch (error: unknown) { this.logOrchestratorError(run.id, error); await this.failRun(run.id, TicketAutomationFailureCode.AGENT_PROVIDER_ERROR); @@ -206,7 +238,7 @@ export class AutonomousRunOrchestratorService { ticketStatusBefore: ticket.status, startedAt: new Date(), iterationCount: 0, - completionMarkerSeen: false, + completionSignalSeen: false, }), ); @@ -234,6 +266,7 @@ export class AutonomousRunOrchestratorService { automation: TicketAutomationEntity, autonomy: ClientAgentAutonomyEntity, agentId: string, + preferredModel: string, ): Promise { const branchesInitial = await this.vcsProxy.getBranches(ticket.clientId, agentId); const baseBranch = @@ -298,54 +331,68 @@ export class AutonomousRunOrchestratorService { includeWorkspace: automation.includeWorkspaceContext !== false, environmentIds: [...new Set([agentId, ...(automation.contextEnvironmentIds ?? [])])], autoEnrichmentEnabled: automation.autoEnrichmentEnabled !== false, + ...(ticket.longSha ? { ticketShas: [ticket.longSha] } : {}), }; if (autonomy.preImproveTicket) { + const ticketPrompt = await this.buildAutomationRunPrompt(ticket); + await this.remoteChat.sendChatSync({ clientId: ticket.clientId, agentId, - message: `${buildAutonomousTicketRunPreamble()}Improve ticket clarity only; do not implement code yet.`, + message: `${buildAutonomousTicketRunPreamble()}${ticketPrompt}\n\nImprove ticket clarity only; do not implement code yet.`, correlationId: `${run.id}:pre-improve`, continue: false, resumeSessionSuffix: '-ticket-auto-pre', + model: preferredModel, statisticsInteractionKind: StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN_TURN, contextInjection, }); } let iteration = 0; - let sawMarker = false; + let sawCompletion = false; + const ticketPrompt = await this.buildAutomationRunPrompt(ticket); while (iteration < autonomy.maxIterations) { iteration += 1; - const text = await this.remoteChat.sendChatSync({ + const turn = await this.remoteChat.sendChatSync({ clientId: ticket.clientId, agentId, message: iteration === 1 - ? `${buildAutonomousTicketRunPreamble()}Implement the ticket in the repository.` - : 'Continue the implementation until the completion marker is present.', + ? `${buildAutonomousTicketRunPreamble()}${ticketPrompt}\n\nImplement this ticket in the repository. Stay scoped to the ticket content above.` + : `${ticketPrompt}\n\nContinue implementing this ticket. Finish the scoped work when possible; the platform will collect turn status after this turn.`, correlationId: `${run.id}:turn:${iteration}`, continue: iteration > 1, resumeSessionSuffix: '-ticket-auto-loop', + model: preferredModel, statisticsInteractionKind: StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN_TURN, contextInjection, + expectAutomationTurnStatus: true, }); - await this.persistStep(run.id, stepIdx++, TicketAutomationRunPhase.AGENT_LOOP, 'agent_turn', { iteration }, text); + await this.persistStep( + run.id, + stepIdx++, + TicketAutomationRunPhase.AGENT_LOOP, + 'agent_turn', + { iteration, turnStatus: turn.turnStatus ?? null }, + turn.text, + ); await this.runRepo.update(run.id, { iterationCount: iteration, phase: TicketAutomationRunPhase.AGENT_LOOP }); this.emitRunSummaryThrottled(ticket.clientId, run.id); - if (text.includes(AGENSTRA_AUTOMATION_COMPLETE)) { - sawMarker = true; - await this.runRepo.update(run.id, { completionMarkerSeen: true }); + if (turn.turnStatus === AGENSTRA_AUTOMATION_TURN_STATUS.COMPLETE) { + sawCompletion = true; + await this.runRepo.update(run.id, { completionSignalSeen: true }); await this.emitRunSummaryNow(ticket.clientId, run.id); break; } } - if (!sawMarker) { - await this.failRun(run.id, TicketAutomationFailureCode.AGENT_NO_COMPLETION_MARKER); + if (!sawCompletion) { + await this.failRun(run.id, TicketAutomationFailureCode.AGENT_NO_COMPLETION_STATUS); return; } @@ -375,7 +422,7 @@ export class AutonomousRunOrchestratorService { this.logger.log(`Run ${run.id}: no verifier commands configured, skipping verification`); } - const finalized = await this.finalizeGitCommitStep(run, ticket, agentId, stepIdx, contextInjection); + const finalized = await this.finalizeGitCommitStep(run, ticket, agentId, stepIdx, contextInjection, preferredModel); if (!finalized.ok) { return; @@ -430,7 +477,13 @@ export class AutonomousRunOrchestratorService { ticket: TicketEntity, agentId: string, stepIdx: number, - contextInjection: { includeWorkspace: boolean; environmentIds: string[]; autoEnrichmentEnabled: boolean }, + contextInjection: { + includeWorkspace: boolean; + environmentIds: string[]; + autoEnrichmentEnabled: boolean; + ticketShas?: string[]; + }, + preferredModel: string, ): Promise<{ ok: true; nextStepIndex: number } | { ok: false }> { await this.runRepo.update(run.id, { phase: TicketAutomationRunPhase.FINALIZE }); const status = await this.vcsProxy.getStatus(ticket.clientId, agentId); @@ -446,7 +499,13 @@ export class AutonomousRunOrchestratorService { } await this.vcsProxy.stageFiles(ticket.clientId, agentId, { files: [] }); - const { message, source } = await this.resolveCommitMessageWithAiFallback(run, ticket, agentId, contextInjection); + const { message, source } = await this.resolveCommitMessageWithAiFallback( + run, + ticket, + agentId, + contextInjection, + preferredModel, + ); try { await this.vcsProxy.commit(ticket.clientId, agentId, { message }); @@ -484,7 +543,13 @@ export class AutonomousRunOrchestratorService { run: TicketAutomationRunEntity, ticket: TicketEntity, agentId: string, - contextInjection: { includeWorkspace: boolean; environmentIds: string[]; autoEnrichmentEnabled: boolean }, + contextInjection: { + includeWorkspace: boolean; + environmentIds: string[]; + autoEnrichmentEnabled: boolean; + ticketShas?: string[]; + }, + preferredModel: string, ): Promise<{ message: string; source: 'ai' | 'fallback' }> { const timeoutMs = parseInt(process.env.REMOTE_AGENT_COMMIT_MESSAGE_TIMEOUT_MS || '120000', 10); @@ -496,11 +561,12 @@ export class AutonomousRunOrchestratorService { correlationId: `${run.id}:commit-msg`, continue: false, resumeSessionSuffix: '-ticket-auto-commit-msg', + model: preferredModel, statisticsInteractionKind: StatisticsInteractionKind.AUTONOMOUS_TICKET_COMMIT_MESSAGE, chatTimeoutMs: timeoutMs, contextInjection, }); - const sanitized = sanitizeConventionalCommitSubject(raw); + const sanitized = sanitizeConventionalCommitSubject(raw.text); if (sanitized) { return { message: sanitized, source: 'ai' }; @@ -541,7 +607,7 @@ export class AutonomousRunOrchestratorService { const runTerminal = code === TicketAutomationFailureCode.HUMAN_ESCALATION ? TicketAutomationRunStatus.ESCALATED - : code === TicketAutomationFailureCode.AGENT_NO_COMPLETION_MARKER + : code === TicketAutomationFailureCode.AGENT_NO_COMPLETION_STATUS ? TicketAutomationRunStatus.TIMED_OUT : TicketAutomationRunStatus.FAILED; let nextStatus: TicketStatus; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts index e4e02e43c..439d1361b 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts @@ -49,6 +49,7 @@ describe('KnowledgeTreeService', () => { const usersRepository: any = { findById: jest.fn() }; const ticketsService: any = { getPrototypePromptByClientSha: jest.fn(), + assertTicketInClient: jest.fn().mockResolvedValue({ id: 'ticket-1', clientId: 'c1' }), }; const ticketBoardRealtime: any = { emitToClient: jest.fn(), @@ -448,17 +449,126 @@ describe('KnowledgeTreeService', () => { expect(result.created[0].title).toBe('Guide (1)'); expect(result.updated).toHaveLength(0); }); + }); - it('rejects path separators in filename', async () => { - (service as any).assertClientAccess = jest.fn().mockResolvedValue(undefined); - nodeRepo.find.mockResolvedValue([]); + it('collects automation relation sections across ticket tree sources with full target depth', async () => { + relationRepo.find + .mockResolvedValueOnce([ + { + targetType: KnowledgeRelationTargetType.TICKET, + targetTicketLongSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + { + targetType: KnowledgeRelationTargetType.FOLDER, + targetNodeId: 'folder-1', + }, + ]) + .mockResolvedValueOnce([ + { + targetType: KnowledgeRelationTargetType.PAGE, + targetNodeId: 'page-child', + }, + ]); + ticketsService.getPrototypePromptByClientSha.mockResolvedValue({ + prompt: '- [related] Related ticket (todo, medium)\n Content:\n Nested work\n', + }); + nodeRepo.findOne + .mockResolvedValueOnce({ id: 'folder-1', clientId: 'c1', nodeType: KnowledgeNodeType.FOLDER }) + .mockResolvedValueOnce({ + id: 'page-child', + clientId: 'c1', + nodeType: KnowledgeNodeType.PAGE, + title: 'Child page', + content: 'From subtask relation', + }); + (service as any).collectSubtreePages = jest.fn().mockResolvedValue([ + { id: 'page-a', title: 'Folder page A', content: 'A' }, + { id: 'page-b', title: 'Folder page B', content: 'B' }, + ]); - const result = await service.uploadTextFiles({ - clientId: 'client-1', - files: [{ filename: '../evil.md', content: 'x' }], + const sections = await service.collectAutomationRelationPromptSections( + 'c1', + ['ticket-root', 'ticket-child'], + ['bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'], + ); + + expect(ticketsService.getPrototypePromptByClientSha).toHaveBeenCalledWith( + 'c1', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ); + expect(sections.some((s) => s.includes('Related ticket'))).toBe(true); + expect(sections.some((s) => s.includes('Folder page A'))).toBe(true); + expect(sections.some((s) => s.includes('Folder page B'))).toBe(true); + expect(sections.some((s) => s.includes('Child page'))).toBe(true); + }); + + it('skips related tickets already covered by the automation ticket tree SHAs', async () => { + relationRepo.find.mockResolvedValue([ + { + targetType: KnowledgeRelationTargetType.TICKET, + targetTicketLongSha: 'cccccccccccccccccccccccccccccccccccccccc', + }, + ]); + + const sections = await service.collectAutomationRelationPromptSections( + 'c1', + ['ticket-root'], + ['cccccccccccccccccccccccccccccccccccccccc'], + ); + + expect(sections).toEqual([]); + expect(ticketsService.getPrototypePromptByClientSha).not.toHaveBeenCalled(); + }); + + it('applies auto-enrich section budget to automation relation prompts', async () => { + const previousSections = process.env.AUTO_ENRICH_MAX_SECTIONS; + const previousChars = process.env.AUTO_ENRICH_MAX_CHARS; + + process.env.AUTO_ENRICH_MAX_SECTIONS = '1'; + process.env.AUTO_ENRICH_MAX_CHARS = '12000'; + relationRepo.find.mockResolvedValue([ + { + targetType: KnowledgeRelationTargetType.PAGE, + targetNodeId: 'page-1', + }, + { + targetType: KnowledgeRelationTargetType.PAGE, + targetNodeId: 'page-2', + }, + ]); + nodeRepo.findOne + .mockResolvedValueOnce({ + id: 'page-1', + clientId: 'c1', + nodeType: KnowledgeNodeType.PAGE, + title: 'P1', + content: 'one', + }) + .mockResolvedValueOnce({ + id: 'page-2', + clientId: 'c1', + nodeType: KnowledgeNodeType.PAGE, + title: 'P2', + content: 'two', }); - expect(result.rejected[0].reason).toMatch(/path separators/i); - }); + try { + const sections = await service.collectAutomationRelationPromptSections('c1', ['ticket-root']); + + expect(sections).toHaveLength(1); + expect(sections[0]).toContain('P1'); + } finally { + if (previousSections === undefined) { + delete process.env.AUTO_ENRICH_MAX_SECTIONS; + } else { + process.env.AUTO_ENRICH_MAX_SECTIONS = previousSections; + } + + if (previousChars === undefined) { + delete process.env.AUTO_ENRICH_MAX_CHARS; + } else { + process.env.AUTO_ENRICH_MAX_CHARS = previousChars; + } + } }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts index a22ba167f..0eece8bb3 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts @@ -1012,6 +1012,22 @@ export class KnowledgeTreeService { throw new BadRequestException('targetNodeId is required for node relations'); } + if (dto.sourceType === KnowledgeRelationSourceType.TICKET) { + await this.ticketsService.assertTicketInClient(dto.sourceId, dto.clientId, req); + } + + if (dto.sourceType === KnowledgeRelationSourceType.PAGE) { + const sourcePage = await this.getNodeOrThrow(dto.sourceId); + + if (sourcePage.clientId !== dto.clientId) { + throw new ForbiddenException('Relation source page must belong to same workspace'); + } + + if (sourcePage.nodeType !== KnowledgeNodeType.PAGE) { + throw new BadRequestException('sourceId must reference a page when sourceType is page'); + } + } + if (dto.targetNodeId) { const target = await this.getNodeOrThrow(dto.targetNodeId); @@ -1027,6 +1043,14 @@ export class KnowledgeTreeService { } } + if (dto.targetType === KnowledgeRelationTargetType.TICKET && dto.targetTicketSha) { + const related = await this.ticketsService.getPrototypePromptByClientSha(dto.clientId, dto.targetTicketSha); + + if (!related?.prompt) { + throw new NotFoundException('Relation target ticket was not found in this workspace'); + } + } + const relation = await this.knowledgeRelationRepo.save( this.knowledgeRelationRepo.create({ clientId: dto.clientId, @@ -1178,22 +1202,92 @@ export class KnowledgeTreeService { req?: RequestWithUser, ): Promise { await this.assertClientAccess(clientId, req); + + return this.collectPromptContextsForSourceInternal(clientId, sourceType, sourceId); + } + + /** + * System-only: collect relation prompt sections for many ticket sources (unattended automation). + * Walks selected ticket + nested subtasks (same depth as subtask injection). + * Related tickets are expanded with full prototype trees; related folders expand subtree pages. + * Applies the same section/char budget as interactive auto-enrichment. + */ + async collectAutomationRelationPromptSections( + clientId: string, + ticketIds: string[], + excludeTicketLongShas: string[] = [], + ): Promise { + const uniqTicketIds = Array.from(new Set(ticketIds.map((id) => id.trim()).filter((id) => id.length > 0))); + const sections: string[] = []; + const seenPageIds = new Set(); + const seenTicketShas = new Set( + excludeTicketLongShas.map((sha) => sha.trim().toLowerCase()).filter((sha) => sha.length > 0), + ); + + for (const ticketId of uniqTicketIds) { + const related = await this.collectPromptContextsForSourceInternal( + clientId, + KnowledgeRelationSourceType.TICKET, + ticketId, + { seenPageIds, seenTicketShas }, + ); + + sections.push(...related.promptSections); + } + + return this.applyAutomationRelationSectionBudget( + Array.from(new Set(sections.map((section) => section.trim()).filter((section) => section.length > 0))), + ); + } + + private applyAutomationRelationSectionBudget(sections: string[]): string[] { + const maxSections = parseInt(process.env.AUTO_ENRICH_MAX_SECTIONS || '6', 10); + const maxChars = parseInt(process.env.AUTO_ENRICH_MAX_CHARS || '12000', 10); + const accepted: string[] = []; + let totalChars = 0; + + for (const section of sections) { + if (accepted.length >= maxSections) { + break; + } + + const nextChars = totalChars + section.length; + + if (nextChars > maxChars) { + break; + } + + accepted.push(section); + totalChars = nextChars; + } + + return accepted; + } + + private async collectPromptContextsForSourceInternal( + clientId: string, + sourceType: KnowledgeRelationSourceType, + sourceId: string, + dedupe?: { seenPageIds: Set; seenTicketShas: Set }, + ): Promise { const relations = await this.knowledgeRelationRepo.find({ where: { clientId, sourceType, sourceId }, order: { createdAt: 'ASC' }, }); const sections: string[] = []; - const seenPageIds = new Set(); - const seenTicketShas = new Set(); + const seenPageIds = dedupe?.seenPageIds ?? new Set(); + const seenTicketShas = dedupe?.seenTicketShas ?? new Set(); for (const rel of relations) { if (rel.targetType === KnowledgeRelationTargetType.TICKET && rel.targetTicketLongSha) { - if (seenTicketShas.has(rel.targetTicketLongSha)) { + const sha = rel.targetTicketLongSha.trim().toLowerCase(); + + if (!sha || seenTicketShas.has(sha)) { continue; } - seenTicketShas.add(rel.targetTicketLongSha); - const ticketPrompt = await this.ticketsService.getPrototypePromptByClientSha(clientId, rel.targetTicketLongSha); + seenTicketShas.add(sha); + const ticketPrompt = await this.ticketsService.getPrototypePromptByClientSha(clientId, sha); if (ticketPrompt?.prompt) { sections.push(ticketPrompt.prompt); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.spec.ts new file mode 100644 index 000000000..1d177bf18 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.spec.ts @@ -0,0 +1,30 @@ +import { AGENSTRA_OPENCODE_PLATFORM_WIRE_VERSION } from '@forepath/agenstra/shared/util-opencode-config'; + +import { OpencodeConfigSyncTargetsService } from './opencode-config-sync-targets.service'; + +describe('OpencodeConfigSyncTargetsService.hashRevision', () => { + it('changes when platform wire version is reflected in the payload', () => { + const config = { model: 'azure/gpt-5.5', agents: {} }; + const secrets = { AZURE_API_KEY: 'x' }; + const hash = OpencodeConfigSyncTargetsService.hashRevision(config, secrets); + + expect(hash).toHaveLength(64); + expect(hash).not.toEqual( + // Overlay-only hash (pre-fix) must differ once platform inject + version are included. + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('crypto') + .createHash('sha256') + .update(JSON.stringify({ config, secrets: { AZURE_API_KEY: 'x' } })) + .digest('hex'), + ); + expect(AGENSTRA_OPENCODE_PLATFORM_WIRE_VERSION).toBeGreaterThan(0); + }); + + it('is stable for the same effective config and secrets', () => { + const config = { model: 'azure/gpt-5.5', agents: { test: { mode: 'primary' } } }; + const a = OpencodeConfigSyncTargetsService.hashRevision(config, { B: '2', A: '1' }); + const b = OpencodeConfigSyncTargetsService.hashRevision(config, { A: '1', B: '2' }); + + expect(a).toBe(b); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.ts index d16394cb2..8694134cb 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-config-sync-targets.service.ts @@ -1,5 +1,10 @@ import { createHash } from 'crypto'; +import { + AGENSTRA_OPENCODE_PLATFORM_WIRE_VERSION, + prepareConfigForSync, + type JsonObject, +} from '@forepath/agenstra/shared/util-opencode-config'; import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; @@ -35,6 +40,11 @@ export class OpencodeConfigSyncTargetsService { private readonly clientAgentProxy: ClientAgentProxyService, ) {} + /** + * Hash desired worker wire + secrets. + * Includes platform wire version and prepareConfigForSync output so inject/materialize + * deploys rematch even when stored overlays are unchanged. + */ static hashRevision(config: Record, secrets: Record): string { const sortedSecrets: Record = {}; @@ -42,8 +52,16 @@ export class OpencodeConfigSyncTargetsService { sortedSecrets[key] = secrets[key]; } + const prepared = prepareConfigForSync(config as JsonObject); + return createHash('sha256') - .update(JSON.stringify({ config, secrets: sortedSecrets })) + .update( + JSON.stringify({ + platformWireVersion: AGENSTRA_OPENCODE_PLATFORM_WIRE_VERSION, + config: prepared, + secrets: sortedSecrets, + }), + ) .digest('hex'); } @@ -185,6 +203,53 @@ export class OpencodeConfigSyncTargetsService { return rows.map((row) => row.id); } + /** + * Recompute desired revisions for synced targets; mark pending when platform wire / overlays drift. + * Returns ids newly marked pending (capped by max). + */ + async reconcileOutdatedSyncedTargets(max: number): Promise { + if (max <= 0) { + return []; + } + + const synced = await this.targetsRepo.find({ + where: { syncStatus: 'synced' }, + order: { updatedAt: 'ASC' }, + take: Math.max(max * 4, max), + }); + const pendingIds: string[] = []; + + for (const target of synced) { + if (pendingIds.length >= max) { + break; + } + + try { + const { effective, secrets } = await this.effectiveConfigSync.buildEffectivePayload( + target.clientId, + target.agentId, + ); + const desiredRevision = OpencodeConfigSyncTargetsService.hashRevision(effective, secrets); + + if (desiredRevision === target.appliedRevision && desiredRevision === target.desiredRevision) { + continue; + } + + target.desiredRevision = desiredRevision; + target.syncStatus = 'pending'; + target.lastError = null; + await this.targetsRepo.save(target); + pendingIds.push(target.id); + } catch (error: unknown) { + const err = error as { message?: string }; + + this.logger.warn(`Failed to reconcile OpenCode sync target ${target.id}: ${err.message ?? 'unknown'}`); + } + } + + return pendingIds; + } + async findPendingTargetIdsForClient(clientId: string, max: number): Promise { const rows = await this.targetsRepo.find({ where: { clientId, syncStatus: In(['pending', 'failed']) }, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts index 38e88ee48..0d6ab9c36 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts @@ -3,11 +3,13 @@ import { AuthenticationType } from '@forepath/identity/backend'; import { ClientAgentCredentialsRepository } from '@forepath/identity/backend'; import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import type { Socket as ClientSocket } from 'socket.io-client'; +import type { AgenstraAutomationTurnStatus } from '@forepath/agenstra/shared/util-opencode-config'; import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; import { ClientsRepository } from '../repositories/clients.repository'; import { getClientEndpointTlsPolicy, validateClientEndpointWithDnsOrThrow } from '../utils/client-endpoint-security'; import { buildRemoteAgentsSocketUrl } from '../utils/remote-manager-url.utils'; +import { extractAutomationTurnStatus } from '../utils/automation-turn-status'; import { ClientsService } from './clients.service'; import { StatisticsService } from './statistics.service'; @@ -19,6 +21,8 @@ export interface RemoteChatSyncParams { correlationId: string; continue?: boolean; resumeSessionSuffix?: string; + /** OpenCode `provider/model` — required for automation (no auto model). */ + model?: string; /** When set, records statistics under this kind instead of default chat. */ statisticsInteractionKind?: StatisticsInteractionKind; /** Overrides `REMOTE_AGENT_CHAT_TIMEOUT_MS` for the agent response wait (e.g. shorter commit-message generation). */ @@ -27,7 +31,18 @@ export interface RemoteChatSyncParams { includeWorkspace?: boolean; environmentIds?: string[]; autoEnrichmentEnabled?: boolean; + ticketShas?: string[]; }; + /** + * When true, settle only on final `result` frames (ignore intermediate status/question payloads) + * and return structured automation turn status when present. + */ + expectAutomationTurnStatus?: boolean; +} + +export interface RemoteChatSyncResult { + text: string; + turnStatus?: AgenstraAutomationTurnStatus; } /** @@ -92,13 +107,79 @@ export class RemoteAgentsSessionService { return typeof res === 'string' ? res : JSON.stringify(res); } + if (r && typeof r === 'object' && String((r as { type?: unknown }).type) === 'agenstra_turn') { + const parts = (r as { parts?: unknown }).parts; + + if (Array.isArray(parts)) { + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i]; + + if (part && typeof part === 'object' && String((part as { type?: unknown }).type) === 'result') { + const res = (part as { result?: unknown }).result; + + if (typeof res === 'string') { + return res; + } + + if (res !== undefined) { + return JSON.stringify(res); + } + } + } + } + } + return JSON.stringify(r); } + private isFinalAgentResult(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') { + return false; + } + + const envelope = payload as { success?: boolean; data?: { from?: string; response?: unknown } }; + + if (!envelope.success || !envelope.data || envelope.data.from !== 'agent') { + return false; + } + + const r = envelope.data.response; + + if (!r || typeof r !== 'object') { + return false; + } + + const type = String((r as { type?: unknown }).type); + + if (type === 'result') { + const record = r as { is_error?: unknown; subtype?: unknown }; + + if (record.is_error === true || String(record.subtype ?? '') === 'error') { + return false; + } + + return true; + } + + if (type === 'agenstra_turn' && Array.isArray((r as { parts?: unknown }).parts)) { + return (r as { parts: unknown[] }).parts.some((part) => { + if (!part || typeof part !== 'object') { + return false; + } + + const record = part as { type?: unknown; is_error?: unknown; subtype?: unknown }; + + return String(record.type) === 'result' && record.is_error !== true && String(record.subtype ?? '') !== 'error'; + }); + } + + return false; + } + /** * Connects to the remote agents gateway, logs in, sends one non-streaming `chat`, returns aggregated agent text. */ - async sendChatSync(params: RemoteChatSyncParams): Promise { + async sendChatSync(params: RemoteChatSyncParams): Promise { const client = await this.clientsRepository.findByIdOrThrow(params.clientId); const authHeader = await this.getAuthHeader(params.clientId); @@ -118,6 +199,7 @@ export class RemoteAgentsSessionService { } const chatTimeoutMs = params.chatTimeoutMs ?? parseInt(process.env.REMOTE_AGENT_CHAT_TIMEOUT_MS || '600000', 10); + const expectAutomation = params.expectAutomationTurnStatus === true; try { await new Promise((resolve, reject) => { @@ -162,8 +244,10 @@ export class RemoteAgentsSessionService { kind, ); - const output = await new Promise((resolve, reject) => { + const output = await new Promise((resolve, reject) => { let settled = false; + let lastText = ''; + let lastStatus: AgenstraAutomationTurnStatus | undefined; const t = setTimeout(() => { if (!settled) { settled = true; @@ -173,12 +257,36 @@ export class RemoteAgentsSessionService { }, chatTimeoutMs); const onChatMessage = (msg: unknown) => { const text = this.extractAgentText(msg); + const turnStatus = extractAutomationTurnStatus(msg); + + if (text) { + lastText = text; + } + + if (turnStatus) { + lastStatus = turnStatus; + } + + if (expectAutomation) { + if (!this.isFinalAgentResult(msg)) { + return; + } + + if (!settled) { + settled = true; + clearTimeout(t); + remote.off('chatMessage', onChatMessage); + resolve({ text: lastText || text, turnStatus: lastStatus ?? turnStatus }); + } + + return; + } if (text && !settled) { settled = true; clearTimeout(t); remote.off('chatMessage', onChatMessage); - resolve(text); + resolve({ text, turnStatus: lastStatus ?? turnStatus }); } }; @@ -191,15 +299,18 @@ export class RemoteAgentsSessionService { continue: params.continue ?? false, resumeSessionSuffix: params.resumeSessionSuffix, contextInjection: params.contextInjection, + ...(params.model ? { model: params.model } : {}), + // Trust marker required by agent-manager for -ticket-auto-* allow-all sessions. + unattendedAutomation: true, }); }); - const outWords = output.trim().split(/\s+/).filter(Boolean).length; + const outWords = output.text.trim().split(/\s+/).filter(Boolean).length; await this.statisticsService.recordChatOutput( params.clientId, params.agentId, outWords, - output.length, + output.text.length, undefined, kind, ); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.spec.ts index 55fc2f4b3..0c6124965 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.spec.ts @@ -62,7 +62,7 @@ describe('TicketAutomationChatSyncService', () => { updatedAt: new Date('2020-01-02'), finishedAt: new Date('2020-01-02'), iterationCount: 1, - completionMarkerSeen: true, + completionSignalSeen: true, verificationPassed: true, failureCode: null, summary: null, @@ -111,7 +111,7 @@ describe('TicketAutomationChatSyncService', () => { updatedAt: new Date('2020-01-02'), finishedAt: new Date('2020-01-02'), iterationCount: 1, - completionMarkerSeen: true, + completionSignalSeen: true, verificationPassed: true, failureCode: null, summary: null, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation.service.spec.ts index b76f8e85b..204efac31 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation.service.spec.ts @@ -98,6 +98,7 @@ describe('TicketAutomationService', () => { Promise.resolve({ ...row, allowedAgentIds: row.allowedAgentIds ?? [], + preferredModel: row.preferredModel ?? null, requiresApproval: row.requiresApproval ?? false, consecutiveFailureCount: 0, createdAt: new Date('2020-01-01'), @@ -122,6 +123,7 @@ describe('TicketAutomationService', () => { ticketId: tid, eligible: false, allowedAgentIds: [] as string[], + preferredModel: null as string | null, includeWorkspaceContext: true, contextEnvironmentIds: [] as string[], autoEnrichmentEnabled: true, @@ -148,10 +150,13 @@ describe('TicketAutomationService', () => { it('logs eligibility change without approval invalidated when approval is not required', async () => { automationRepo.findOne.mockResolvedValue(baseAutomation({ eligible: false, requiresApproval: false })); - await service.patchAutomation(tid, { eligible: true }, undefined); + await service.patchAutomation(tid, { eligible: true, preferredModel: 'opencode/test-model' }, undefined); const types = activityRepo.save.mock.calls.map((c) => (c[0] as { actionType: string }).actionType); - expect(types).toEqual([TicketActionType.AUTOMATION_ELIGIBILITY_CHANGED]); + expect(types).toEqual([ + TicketActionType.AUTOMATION_ELIGIBILITY_CHANGED, + TicketActionType.AUTOMATION_SETTINGS_UPDATED, + ]); }); it('logs approval requirement change only when turning off requirement (no approval invalidated)', async () => { @@ -176,6 +181,7 @@ describe('TicketAutomationService', () => { automationRepo.findOne.mockResolvedValue( baseAutomation({ eligible: true, + preferredModel: 'opencode/test-model', requiresApproval: true, approvedAt, approvedByUserId: 'user-1', @@ -200,7 +206,11 @@ describe('TicketAutomationService', () => { it('does not save or log when patch is a no-op', async () => { automationRepo.findOne.mockResolvedValue( - baseAutomation({ eligible: true, allowedAgentIds: ['00000000-0000-4000-8000-0000000000aa'] }), + baseAutomation({ + eligible: true, + preferredModel: 'opencode/test-model', + allowedAgentIds: ['00000000-0000-4000-8000-0000000000aa'], + }), ); await service.patchAutomation( tid, @@ -313,6 +323,7 @@ describe('TicketAutomationService', () => { .mockResolvedValueOnce({ ticketId: tid, eligible: true, + preferredModel: 'opencode/test-model', allowedAgentIds: [], verifierProfile: null, requiresApproval: true, @@ -370,6 +381,7 @@ describe('TicketAutomationService', () => { const row = { ticketId: tid, eligible: true, + preferredModel: 'opencode/test-model', allowedAgentIds: [], verifierProfile: null, requiresApproval: true, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts index 56e94a513..d4f940cce 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts @@ -40,6 +40,7 @@ import { } from '../utils/ticket-automation-branch.constants'; import { ticketActivityEntityToDto } from '../utils/ticket-board-realtime-mappers'; import { parseAndValidateVerifierProfile } from '../utils/verifier-profile.validation'; +import { assertValidPreferredModel } from '../utils/preferred-model.utils'; import { TicketAutomationChatSyncService } from './ticket-automation-chat-sync.service'; import { TICKETS_BOARD_EVENTS } from './ticket-board-realtime.constants'; @@ -49,6 +50,7 @@ import { TicketsService } from './tickets.service'; const APPROVAL_RELEVANT_AUTOMATION_FIELDS = new Set([ 'eligible', 'allowedAgentIds', + 'preferredModel', 'includeWorkspaceContext', 'contextEnvironmentIds', 'autoEnrichmentEnabled', @@ -158,6 +160,7 @@ export class TicketAutomationService { ticketId: row.ticketId, eligible: row.eligible, allowedAgentIds: row.allowedAgentIds ?? [], + preferredModel: row.preferredModel ?? null, includeWorkspaceContext: row.includeWorkspaceContext !== false, contextEnvironmentIds: sortUuidList(row.contextEnvironmentIds ?? []), autoEnrichmentEnabled: row.autoEnrichmentEnabled !== false, @@ -195,6 +198,7 @@ export class TicketAutomationService { const prevRequiresApproval = row.requiresApproval; const prevApprovedAt = row.approvedAt; const prevAllowedSorted = sortUuidList(row.allowedAgentIds ?? []); + const prevPreferredModel = row.preferredModel ?? null; const prevIncludeWorkspace = row.includeWorkspaceContext !== false; const prevContextEnvSorted = sortUuidList(row.contextEnvironmentIds ?? []); const prevAutoEnrichmentEnabled = row.autoEnrichmentEnabled !== false; @@ -224,6 +228,22 @@ export class TicketAutomationService { } } + if (dto.preferredModel !== undefined) { + const nextModel = assertValidPreferredModel(dto.preferredModel); + + if (nextModel !== prevPreferredModel) { + row.preferredModel = nextModel; + actuallyChanged.push('preferredModel'); + } + } + + const nextEligible = dto.eligible !== undefined ? dto.eligible : row.eligible; + const nextPreferredModel = row.preferredModel ?? null; + + if (nextEligible === true && !nextPreferredModel) { + throw new BadRequestException('preferredModel is required when automation is eligible'); + } + if (dto.includeWorkspaceContext !== undefined && dto.includeWorkspaceContext !== prevIncludeWorkspace) { row.includeWorkspaceContext = dto.includeWorkspaceContext; actuallyChanged.push('includeWorkspaceContext'); @@ -312,6 +332,7 @@ export class TicketAutomationService { const settingsDetailFields = actuallyChanged.filter( (k) => k === 'allowedAgentIds' || + k === 'preferredModel' || k === 'includeWorkspaceContext' || k === 'contextEnvironmentIds' || k === 'autoEnrichmentEnabled' || @@ -638,7 +659,7 @@ export class TicketAutomationService { finishedAt: r.finishedAt ?? null, updatedAt: r.updatedAt, iterationCount: r.iterationCount, - completionMarkerSeen: r.completionMarkerSeen, + completionSignalSeen: r.completionSignalSeen, verificationPassed: r.verificationPassed ?? null, failureCode: r.failureCode ?? null, summary: r.summary ?? null, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts index db84c9da5..da6fbde19 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts @@ -59,6 +59,7 @@ import { } from '../search/agenstra-search-list.util'; import { buildSpecificationSubtaskSeeds } from '../utils/specification-ticket-subtasks.utils'; import { derivePatchActionType, type FieldChange } from '../utils/ticket-activity-payload.utils'; +import { assertValidPreferredModel } from '../utils/preferred-model.utils'; import { buildDescendantCheckboxTaskTotalsByTicketId, countMarkdownCheckboxTasks, @@ -645,6 +646,16 @@ export class TicketsService { } } + if (dto.preferredChatModel !== undefined) { + const newModel = assertValidPreferredModel(dto.preferredChatModel); + const oldModel = ticket.preferredChatModel ?? null; + + if (newModel !== oldModel) { + changes.preferredChatModel = { old: oldModel, new: newModel }; + ticket.preferredChatModel = newModel; + } + } + if (dto.parentId !== undefined) { const newParentId = dto.parentId; @@ -1146,6 +1157,64 @@ export class TicketsService { return ticket?.id ?? null; } + /** + * Ticket tree prompt body for unattended automation (no activity side effects). + */ + async buildAutomationTicketPromptBody(ticket: TicketEntity): Promise { + return this.buildPrototypePromptBody(ticket); + } + + /** + * Ticket ids used as relation sources for automation: selected ticket + nested subtasks + * (same depth as subtask injection; parents are in the tree prompt but not relation-expanded). + */ + async listAutomationRelationSourceTicketIds(ticket: TicketEntity): Promise { + return [ticket.id, ...(await this.collectDescendantTicketIds(ticket.id))]; + } + + /** + * Ensure a ticket exists in the given workspace (and is readable when {@link req} is present). + */ + async assertTicketInClient(ticketId: string, clientId: string, req?: RequestWithUser): Promise { + const ticket = req ? await this.assertTicketReadable(ticketId, req) : await this.loadTicketOrThrow(ticketId); + + if (ticket.clientId !== clientId) { + throw new ForbiddenException('Relation source ticket must belong to same workspace'); + } + + return ticket; + } + + /** + * Long SHAs already covered by the automation ticket tree (skip when expanding related tickets). + */ + async listAutomationPromptTicketLongShas(ticket: TicketEntity): Promise { + const parentChain = await this.loadParentChainEntities(ticket); + const ids = [ticket.id, ...(await this.collectDescendantTicketIds(ticket.id)), ...parentChain.map((row) => row.id)]; + const rows = ids.length ? await this.ticketRepo.find({ where: { id: In(ids) }, select: ['id', 'longSha'] }) : []; + const shas = rows + .map((row) => row.longSha?.trim().toLowerCase()) + .filter((sha): sha is string => !!sha && sha.length > 0); + + return Array.from(new Set(shas)); + } + + private async collectDescendantTicketIds(ticketId: string): Promise { + const children = await this.ticketRepo.find({ + where: { parentId: ticketId }, + select: ['id'], + order: { createdAt: 'ASC' }, + }); + const ids: string[] = []; + + for (const child of children) { + ids.push(child.id); + ids.push(...(await this.collectDescendantTicketIds(child.id))); + } + + return ids; + } + private async buildPrototypePromptBody(ticket: TicketEntity): Promise { const parentChain = await this.loadParentChainEntities(ticket); const tree = await this.buildPromptNode(ticket); @@ -1395,6 +1464,7 @@ export class TicketsService { createdByUserId: row.createdByUserId, createdByEmail, preferredChatAgentId: row.preferredChatAgentId ?? null, + preferredChatModel: row.preferredChatModel ?? null, automationEligible, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-completion.constants.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-completion.constants.ts deleted file mode 100644 index 03099a187..000000000 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-completion.constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Agents must emit this exact marker in assistant text before verification runs. - * Keep in sync with automation preamble in `tickets-prototype-prompt.utils.ts`. - */ -export const AGENSTRA_AUTOMATION_COMPLETE = 'AGENSTRA_AUTOMATION_COMPLETE'; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.spec.ts index a6ea230e2..6b51e920d 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.spec.ts @@ -30,6 +30,11 @@ describe('routeAutomationFailure', () => { ticketStatus: TicketStatus.IN_PROGRESS, requeue: true, }, + [TicketAutomationFailureCode.AGENT_NO_COMPLETION_STATUS]: { + runStatus: 'timed_out', + ticketStatus: TicketStatus.TODO, + requeue: true, + }, [TicketAutomationFailureCode.AGENT_NO_COMPLETION_MARKER]: { runStatus: 'timed_out', ticketStatus: TicketStatus.TODO, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.ts index 7240a3ccb..cba029b59 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-failure-routing.ts @@ -25,6 +25,7 @@ export function routeAutomationFailure(code: TicketAutomationFailureCode): Autom return { runStatus: 'failed', ticketStatus: TicketStatus.IN_PROGRESS, requeue: false }; case TicketAutomationFailureCode.AGENT_PROVIDER_ERROR: return { runStatus: 'failed', ticketStatus: TicketStatus.IN_PROGRESS, requeue: true }; + case TicketAutomationFailureCode.AGENT_NO_COMPLETION_STATUS: case TicketAutomationFailureCode.AGENT_NO_COMPLETION_MARKER: return { runStatus: 'timed_out', ticketStatus: TicketStatus.TODO, requeue: true }; case TicketAutomationFailureCode.MARKER_WITHOUT_VERIFY: diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-turn-status.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-turn-status.spec.ts new file mode 100644 index 000000000..e04307e1f --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-turn-status.spec.ts @@ -0,0 +1,52 @@ +import { + coerceAutomationTurnStatus, + extractAutomationTurnStatus, + isAutomationTurnComplete, +} from './automation-turn-status'; + +describe('automation-turn-status', () => { + it('extracts status from result automationTurnStatus', () => { + expect( + extractAutomationTurnStatus({ + success: true, + data: { + from: 'agent', + response: { type: 'result', result: 'done', automationTurnStatus: 'complete' }, + }, + }), + ).toBe('complete'); + }); + + it('does not treat free-form result text as status', () => { + expect( + extractAutomationTurnStatus({ + success: true, + data: { + from: 'agent', + response: { type: 'result', result: 'complete' }, + }, + }), + ).toBeUndefined(); + }); + + it('extracts status from agenstra_turn parts', () => { + expect( + extractAutomationTurnStatus({ + success: true, + data: { + from: 'agent', + response: { + type: 'agenstra_turn', + parts: [{ type: 'result', result: 'ok', automationTurnStatus: 'continue' }], + }, + }, + }), + ).toBe('continue'); + }); + + it('coerces and detects complete', () => { + expect(coerceAutomationTurnStatus({ status: 'complete' })).toBe('complete'); + expect(isAutomationTurnComplete('complete')).toBe(true); + expect(isAutomationTurnComplete('continue')).toBe(false); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-turn-status.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-turn-status.ts new file mode 100644 index 000000000..4f190b3ab --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/automation-turn-status.ts @@ -0,0 +1,67 @@ +import { + isAgenstraAutomationTurnStatus, + parseAgenstraAutomationTurnStatus, + type AgenstraAutomationTurnStatus, +} from '@forepath/agenstra/shared/util-opencode-config'; + +export type { AgenstraAutomationTurnStatus }; + +export const AGENSTRA_AUTOMATION_TURN_STATUS = { + CONTINUE: 'continue', + COMPLETE: 'complete', +} as const; + +/** + * Extract automation turn status from a sync chatMessage envelope or agent response object. + */ +export function extractAutomationTurnStatus(payload: unknown): AgenstraAutomationTurnStatus | undefined { + if (!payload || typeof payload !== 'object') { + return undefined; + } + + const envelope = payload as { + success?: boolean; + data?: { from?: string; response?: unknown }; + automationTurnStatus?: unknown; + }; + + if (envelope.automationTurnStatus !== undefined) { + return parseAgenstraAutomationTurnStatus(envelope.automationTurnStatus); + } + + const response = envelope.data?.response ?? envelope; + + if (!response || typeof response !== 'object') { + return undefined; + } + + const record = response as Record; + + if (record['automationTurnStatus'] !== undefined) { + return parseAgenstraAutomationTurnStatus(record['automationTurnStatus']); + } + + if (record['type'] === 'result' && record['result'] !== undefined && typeof record['result'] !== 'string') { + return parseAgenstraAutomationTurnStatus(record['result']); + } + + if (record['type'] === 'agenstra_turn' && Array.isArray(record['parts'])) { + for (const part of record['parts']) { + const status = extractAutomationTurnStatus(part); + + if (status) { + return status; + } + } + } + + return undefined; +} + +export function isAutomationTurnComplete(status: AgenstraAutomationTurnStatus | undefined): boolean { + return status === AGENSTRA_AUTOMATION_TURN_STATUS.COMPLETE; +} + +export function coerceAutomationTurnStatus(value: unknown): AgenstraAutomationTurnStatus | undefined { + return isAgenstraAutomationTurnStatus(value) ? value : parseAgenstraAutomationTurnStatus(value); +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/preferred-model.utils.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/preferred-model.utils.spec.ts new file mode 100644 index 000000000..0a2a1aafd --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/preferred-model.utils.spec.ts @@ -0,0 +1,24 @@ +import { BadRequestException } from '@nestjs/common'; + +import { assertValidPreferredModel, normalizePreferredModel } from './preferred-model.utils'; + +describe('preferred-model.utils', () => { + it('normalizePreferredModel trims and maps empty to null', () => { + expect(normalizePreferredModel(null)).toBeNull(); + expect(normalizePreferredModel(undefined)).toBeNull(); + expect(normalizePreferredModel('')).toBeNull(); + expect(normalizePreferredModel(' ')).toBeNull(); + expect(normalizePreferredModel(' opencode/gpt-5 ')).toBe('opencode/gpt-5'); + }); + + it('assertValidPreferredModel accepts provider/model ids', () => { + expect(assertValidPreferredModel('anthropic/claude-sonnet-4')).toBe('anthropic/claude-sonnet-4'); + expect(assertValidPreferredModel('opencode/gpt-5.2')).toBe('opencode/gpt-5.2'); + }); + + it('assertValidPreferredModel rejects invalid shapes', () => { + expect(() => assertValidPreferredModel('noshslash')).toThrow(BadRequestException); + expect(() => assertValidPreferredModel('/only-model')).toThrow(BadRequestException); + expect(() => assertValidPreferredModel('provider/')).toThrow(BadRequestException); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/preferred-model.utils.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/preferred-model.utils.ts new file mode 100644 index 000000000..69f5e909a --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/preferred-model.utils.ts @@ -0,0 +1,34 @@ +import { BadRequestException } from '@nestjs/common'; + +/** Max length stored for preferred OpenCode `provider/model` strings. */ +export const PREFERRED_MODEL_MAX_LENGTH = 256; + +/** + * OpenCode model option: `providerID/modelID` (slash required). + * Allows common provider/model id characters used by the catalog. + */ +export const PREFERRED_MODEL_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}\/[a-zA-Z0-9][a-zA-Z0-9._+/-]{0,127}$/; + +export function normalizePreferredModel(value: string | null | undefined): string | null { + if (value === null || value === undefined) { + return null; + } + + const trimmed = value.trim(); + + return trimmed === '' ? null : trimmed; +} + +export function assertValidPreferredModel(value: string | null | undefined): string | null { + const normalized = normalizePreferredModel(value); + + if (normalized === null) { + return null; + } + + if (normalized.length > PREFERRED_MODEL_MAX_LENGTH || !PREFERRED_MODEL_PATTERN.test(normalized)) { + throw new BadRequestException('Invalid preferred model; expected provider/model'); + } + + return normalized; +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/ticket-board-realtime-mappers.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/ticket-board-realtime-mappers.ts index e69c4dbcd..08fa2c3ff 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/ticket-board-realtime-mappers.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/ticket-board-realtime-mappers.ts @@ -37,7 +37,7 @@ export function ticketAutomationRunEntityToDto(r: TicketAutomationRunEntity): Ti finishedAt: r.finishedAt ?? null, updatedAt: r.updatedAt, iterationCount: r.iterationCount, - completionMarkerSeen: r.completionMarkerSeen, + completionSignalSeen: r.completionSignalSeen, verificationPassed: r.verificationPassed ?? null, failureCode: r.failureCode ?? null, summary: r.summary ?? null, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.spec.ts index 96c297db4..f4e6b65a4 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.spec.ts @@ -1,7 +1,10 @@ import { TicketPriority, TicketStatus } from '../entities/ticket.enums'; -import { AGENSTRA_AUTOMATION_COMPLETE } from './automation-completion.constants'; -import { buildAutonomousTicketRunPreamble, buildPrototypePrompt } from './tickets-prototype-prompt.utils'; +import { + appendRelatedContextSections, + buildAutonomousTicketRunPreamble, + buildPrototypePrompt, +} from './tickets-prototype-prompt.utils'; describe('tickets-prototype-prompt.utils', () => { it('includes nested children in prompt text', () => { @@ -29,9 +32,26 @@ describe('tickets-prototype-prompt.utils', () => { expect(out).toContain('Child'); }); - it('includes automation completion marker in autonomous preamble', () => { + it('includes structured turn-status instructions in autonomous preamble', () => { const preamble = buildAutonomousTicketRunPreamble(); - expect(preamble).toContain(AGENSTRA_AUTOMATION_COMPLETE); + expect(preamble).toContain('status'); + expect(preamble).toContain('complete'); + expect(preamble).toContain('continue'); + expect(preamble).not.toContain('AGENSTRA_AUTOMATION_COMPLETE'); + }); + + it('appendRelatedContextSections adds labeled related blocks', () => { + const out = appendRelatedContextSections('Ticket tree', ['Knowledge Page: A\nBody', ' ']); + + expect(out).toContain('Ticket tree'); + expect(out).toContain('Related context (from knowledge relations)'); + expect(out).toContain('### Related context 1'); + expect(out).toContain('Knowledge Page: A'); + expect(out).not.toContain('### Related context 2'); + }); + + it('appendRelatedContextSections is a no-op for empty sections', () => { + expect(appendRelatedContextSections('Ticket tree', [])).toBe('Ticket tree'); }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.ts index 49cf65241..bcb297fa9 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/tickets-prototype-prompt.utils.ts @@ -1,6 +1,9 @@ -import { TicketPriority, TicketStatus } from '../entities/ticket.enums'; +/** + * Preamble for autonomous ticket runs: instructs structured OpenCode turn status. + */ +import { AGENSTRA_AUTOMATION_TURN_STATUS_SCHEMA } from '@forepath/agenstra/shared/util-opencode-config'; -import { AGENSTRA_AUTOMATION_COMPLETE } from './automation-completion.constants'; +import { TicketPriority, TicketStatus } from '../entities/ticket.enums'; export interface TicketPromptNode { id: string; @@ -30,17 +33,35 @@ export function buildPrototypePrompt(root: TicketPromptNode, depth = 0): string } export function buildPrototypePromptPreamble(): string { - return `You are helping implement a scoped piece of work. The prompt may include parent tickets for broader scope, then the selected ticket with every nested subtask (title, status, priority, content). Use this hierarchy to produce a concrete prototype or implementation plan as requested by the user.\n\n`; + return `You are helping implement a scoped piece of work. The prompt may include parent tickets for broader scope, then the selected ticket with every nested subtask (title, status, priority, content), then related knowledge/tickets linked via relations (related tickets include their own nested subtasks; related folders include all pages in the subtree). Use this hierarchy and related context to produce a concrete prototype or implementation plan as requested by the user.\n\n`; } /** - * Preamble for autonomous ticket runs: instructs the agent to emit the completion marker when done. + * Appends related-context sections collected from knowledge relations. + */ +export function appendRelatedContextSections(body: string, sections: string[]): string { + const cleaned = sections.map((section) => section.trim()).filter((section) => section.length > 0); + + if (cleaned.length === 0) { + return body; + } + + const labeled = cleaned.map((section, index) => `### Related context ${index + 1}\n${section}`); + + return `${body.trimEnd()}\n\nRelated context (from knowledge relations):\n${labeled.join('\n\n')}\n`; +} + +/** + * Preamble for autonomous ticket runs: implement freely; status is collected after the turn. */ export function buildAutonomousTicketRunPreamble(): string { + const statusEnum = AGENSTRA_AUTOMATION_TURN_STATUS_SCHEMA.properties.status.enum.join(' | '); + return ( buildPrototypePromptPreamble() + - `When the scoped prototype work is complete and ready for verification, you MUST include the exact line ` + - `${AGENSTRA_AUTOMATION_COMPLETE} in your reply (on its own line is best). ` + - `Do not claim completion without that marker.\n\n` + `This is an unattended automation turn. Implement the scoped work using tools as needed. ` + + `When finished with this turn, the platform will collect structured status (${statusEnum}). ` + + `Aim for "complete" only when the scoped prototype is ready for verification; otherwise leave more work for a follow-up turn. ` + + `Do not ask the user questions.\n\n` ); } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.spec.ts index 5805986e7..3dbafe4ff 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.spec.ts @@ -1,8 +1,11 @@ import { PRIMARY_CHAT_RESUME_SESSION_SUFFIX, RESERVED_CHAT_RESUME_SESSION_SUFFIXES, + TICKET_AUTOMATION_RESUME_SESSION_SUFFIXES, buildUserChatResumeSessionSuffix, isReservedChatResumeSessionSuffix, + isTicketAutomationLoopResumeSessionSuffix, + isTicketAutomationResumeSessionSuffix, } from './chat-session.constants'; describe('chat-session.constants', () => { @@ -32,6 +35,26 @@ describe('chat-session.constants', () => { }); }); + describe('isTicketAutomationResumeSessionSuffix', () => { + it('returns true for ticket automation suffixes only', () => { + for (const suffix of TICKET_AUTOMATION_RESUME_SESSION_SUFFIXES) { + expect(isTicketAutomationResumeSessionSuffix(suffix)).toBe(true); + } + + expect(isTicketAutomationResumeSessionSuffix('-prompt-enhance')).toBe(false); + expect(isTicketAutomationResumeSessionSuffix('-chat-abc')).toBe(false); + expect(isTicketAutomationResumeSessionSuffix(undefined)).toBe(false); + }); + }); + + describe('isTicketAutomationLoopResumeSessionSuffix', () => { + it('returns true only for the implementation loop suffix', () => { + expect(isTicketAutomationLoopResumeSessionSuffix('-ticket-auto-loop')).toBe(true); + expect(isTicketAutomationLoopResumeSessionSuffix('-ticket-auto-pre')).toBe(false); + expect(isTicketAutomationLoopResumeSessionSuffix('-ticket-auto-commit-msg')).toBe(false); + }); + }); + describe('buildUserChatResumeSessionSuffix', () => { it('builds -chat-{id} suffix', () => { const chatId = '11111111-2222-4333-8444-555555555555'; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.ts index e311e3f05..b7177de59 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.ts @@ -24,6 +24,13 @@ export const RESERVED_CHAT_RESUME_SESSION_SUFFIXES = [ '-ticket-auto-commit-msg', ] as const; +/** Reserved suffixes used by autonomous ticket-run OpenCode sessions. */ +export const TICKET_AUTOMATION_RESUME_SESSION_SUFFIXES = [ + '-ticket-auto-pre', + '-ticket-auto-loop', + '-ticket-auto-commit-msg', +] as const; + export function isReservedChatResumeSessionSuffix(suffix: string | undefined): boolean { if (suffix === undefined || suffix === PRIMARY_CHAT_RESUME_SESSION_SUFFIX) { return false; @@ -32,6 +39,20 @@ export function isReservedChatResumeSessionSuffix(suffix: string | undefined): b return (RESERVED_CHAT_RESUME_SESSION_SUFFIXES as readonly string[]).includes(suffix); } +/** True for ticket automation hidden sessions (unattended permission / structured turn status). */ +export function isTicketAutomationResumeSessionSuffix(suffix: string | undefined): boolean { + if (suffix === undefined || suffix === PRIMARY_CHAT_RESUME_SESSION_SUFFIX) { + return false; + } + + return (TICKET_AUTOMATION_RESUME_SESSION_SUFFIXES as readonly string[]).includes(suffix); +} + +/** Implementation-loop sessions request structured continue/complete turn status. */ +export function isTicketAutomationLoopResumeSessionSuffix(suffix: string | undefined): boolean { + return suffix === '-ticket-auto-loop'; +} + export function buildUserChatResumeSessionSuffix(chatId: string): string { return `${USER_CHAT_RESUME_SESSION_SUFFIX_PREFIX}${chatId}`; } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts index e01bcf31f..93ea19dc3 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts @@ -2244,6 +2244,7 @@ describe('AgentsGateway', () => { { message: 'Improve ticket', resumeSessionSuffix: '-ticket-auto-loop', + unattendedAutomation: true, // Intentionally omit ephemeral; reserved must still be hidden. chatId: '11111111-2222-4333-8444-555555555555', responseMode: 'sync', @@ -2268,6 +2269,30 @@ describe('AgentsGateway', () => { ); }); + it('rejects ticket-auto resumeSessionSuffix without unattendedAutomation trust marker', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).authenticatedClients.set(mockSocket.id || 'test-socket-id', mockAgent.id); + + await gateway.handleChat( + { + message: 'spoof', + resumeSessionSuffix: '-ticket-auto-loop', + responseMode: 'sync', + correlationId: 'spoof-corr-1', + }, + mockSocket as Socket, + ); + + expect(mockSocket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + success: false, + error: expect.objectContaining({ code: 'AUTOMATION_TRUST_REQUIRED' }), + }), + ); + expect(mockAgentProvider.sendMessage).not.toHaveBeenCalled(); + }); + it('rejects invalid chatId UUID before processing', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (gateway as any).authenticatedClients.set(mockSocket.id || 'test-socket-id', mockAgent.id); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts index 300c5a467..302b1b028 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts @@ -13,7 +13,10 @@ import { Server, Socket } from 'socket.io'; import { v4 as uuidv4 } from 'uuid'; import { GIT_STATE_CHANGED_EVENT, toolMayMutateGitWorkspace } from '../constants/agent-git-state.constants'; -import { isReservedChatResumeSessionSuffix } from '../constants/chat-session.constants'; +import { + isReservedChatResumeSessionSuffix, + isTicketAutomationResumeSessionSuffix, +} from '../constants/chat-session.constants'; import { AgentEventEnvelope, AgentInteractionQueryPayload, AgentResponseMode } from '../providers/agent-events.types'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { AgentResponseObject } from '../providers/agent-provider.interface'; @@ -57,6 +60,11 @@ interface ChatPayload { ephemeral?: boolean; continue?: boolean; resumeSessionSuffix?: string; + /** + * Server-side trust marker for ticket-automation reserved suffixes. + * Only the agent-controller orchestrator may set this; interactive clients must not. + */ + unattendedAutomation?: boolean; /** User-visible chat session id; defaults to primary when omitted (ignored for reserved ACP suffixes). */ chatId?: string; contextInjection?: ContextInjectionPayload; @@ -637,7 +645,12 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, private async resolveChatContext( agentId: string, - data: { chatId?: string; resumeSessionSuffix?: string; ephemeral?: boolean }, + data: { + chatId?: string; + resumeSessionSuffix?: string; + ephemeral?: boolean; + unattendedAutomation?: boolean; + }, ): Promise<{ resumeSessionSuffix: string | undefined; chatSessionId: string | undefined; @@ -645,6 +658,21 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, /** Hidden ACP suffixes must never persist or broadcast into user-visible chats. */ hidden: boolean; }> { + if (isTicketAutomationResumeSessionSuffix(data.resumeSessionSuffix)) { + if (data.unattendedAutomation !== true) { + throw new BadRequestException( + 'Ticket automation resumeSessionSuffix requires unattendedAutomation trust marker', + ); + } + + return { + resumeSessionSuffix: data.resumeSessionSuffix, + chatSessionId: undefined, + chatId: undefined, + hidden: true, + }; + } + if (isReservedChatResumeSessionSuffix(data.resumeSessionSuffix)) { // Ignore chatId: reserved suffixes are isolated ACP sessions, not user chat rows. return { @@ -1567,9 +1595,15 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, chatContext = await this.resolveChatContext(agentUuid, data); } catch (error) { const err = error as { message?: string }; - const code = error instanceof BadRequestException ? 'INVALID_CHAT_ID' : 'CHAT_ERROR'; + const message = err.message || 'Invalid or unknown chat session'; + const code = + error instanceof BadRequestException + ? message.includes('unattendedAutomation') + ? 'AUTOMATION_TRUST_REQUIRED' + : 'INVALID_CHAT_ID' + : 'CHAT_ERROR'; - socket.emit('error', createErrorResponse(err.message || 'Invalid or unknown chat session', code)); + socket.emit('error', createErrorResponse(message, code)); return; } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts index 475da4705..15bc1ebc6 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts @@ -13,6 +13,9 @@ import { migrateConfigV1ToV2, prepareConfigForSync, resolveProviderAuthSecrets, + AGENSTRA_TICKET_AUTOMATION_SKILL_ABS_DIR, + AGENSTRA_TICKET_AUTOMATION_SKILL_MD, + AGENSTRA_TICKET_AUTOMATION_SKILL_REL_DIR, type JsonObject, } from '@forepath/agenstra/shared/util-opencode-config'; import { mcpServerConfigKey } from '@forepath/agenstra/shared/util-opencode-mcp-servers'; @@ -419,6 +422,8 @@ export class OpenCodeConfigSyncService { return { ok: false, error: message }; } + await this.ensurePlatformAutomationSkillFiles(containerId); + return { ok: true }; } catch (error) { const message = formatOpenCodeError(error); @@ -429,6 +434,31 @@ export class OpenCodeConfigSyncService { } } + /** Install the platform ticket-automation skill so OpenCode can load it regardless of UI overlays. */ + private async ensurePlatformAutomationSkillFiles(containerId: string): Promise { + const targets = [ + `${AGENSTRA_TICKET_AUTOMATION_SKILL_ABS_DIR}/SKILL.md`, + `/app/${AGENSTRA_TICKET_AUTOMATION_SKILL_REL_DIR}/SKILL.md`, + ]; + const base64 = Buffer.from(AGENSTRA_TICKET_AUTOMATION_SKILL_MD, 'utf8').toString('base64'); + + for (const filePath of targets) { + const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '.'; + + try { + await this.dockerService.sendCommandToContainer(containerId, [ + 'sh', + '-c', + `mkdir -p ${JSON.stringify(dir)} && printf '%s' ${JSON.stringify(base64)} | base64 -d > ${JSON.stringify(filePath)}`, + ]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + this.logger.warn(`Failed to install platform automation skill at ${filePath}: ${message}`); + } + } + } + /** * Apply network proxy/CA secrets, provider credential env vars, and MCP secretEnv * via Docker Env. Inline PEM CA material is written to a file; env points at that path. diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-event-mapper.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-event-mapper.spec.ts index 9ed8b2def..521c41f5e 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-event-mapper.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-event-mapper.spec.ts @@ -102,6 +102,37 @@ describe('OpenCodeEventMapper', () => { session_id: 'ses_1', usage: { inputTokens: 1, costUsd: 0.01 }, }); + expect(mapper.buildFinalResult('done', 'ses_1', undefined, 'complete')).toEqual({ + type: 'result', + subtype: 'success', + result: 'done', + session_id: 'ses_1', + automationTurnStatus: 'complete', + }); + }); + + it('maps structured_output on message.updated into automationTurnStatus', () => { + const events = mapper.mapEvent({ + type: 'message.updated', + properties: { + info: { + id: 'msg_1', + sessionID: 'ses_1', + role: 'assistant', + structured_output: { status: 'complete', summary: 'ready' }, + }, + }, + }); + + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'status', + subtype: 'structured_output', + automationTurnStatus: 'complete', + }), + ]), + ); }); it('maps permission.updated to question with options', () => { diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-event-mapper.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-event-mapper.ts index 2537d68a8..1ade70c6a 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-event-mapper.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-event-mapper.ts @@ -68,13 +68,19 @@ export class OpenCodeEventMapper { return mapped; } - buildFinalResult(aggregatedText: string, sessionId?: string, usage?: OpenCodeUsagePayload): AgentResponseObject { + buildFinalResult( + aggregatedText: string, + sessionId?: string, + usage?: OpenCodeUsagePayload, + automationTurnStatus?: string, + ): AgentResponseObject { return { type: 'result', subtype: 'success', result: aggregatedText, ...(sessionId ? { session_id: sessionId } : {}), ...(usage ? { usage } : {}), + ...(automationTurnStatus ? { automationTurnStatus } : {}), }; } @@ -376,9 +382,29 @@ export class OpenCodeEventMapper { } | undefined; const cost = typeof info.cost === 'number' ? info.cost : undefined; + const structured = + info['structured_output'] !== undefined + ? info['structured_output'] + : info['structuredOutput'] !== undefined + ? info['structuredOutput'] + : undefined; + const events: AgentResponseObject[] = []; + + if (structured !== undefined) { + events.push({ + type: 'status', + subtype: 'structured_output', + result: structured, + automationTurnStatus: + typeof structured === 'object' && structured && 'status' in (structured as object) + ? (structured as { status?: unknown }).status + : structured, + session_id: typeof info.sessionID === 'string' ? info.sessionID : undefined, + }); + } if (!tokens && cost === undefined) { - return []; + return events; } toolState.lastUsage = { @@ -390,7 +416,7 @@ export class OpenCodeEventMapper { ...(cost !== undefined ? { costUsd: cost } : {}), }; - return []; + return events; } private mapPermissionQuestion(args: { @@ -729,6 +755,25 @@ export class OpenCodeEventMapper { : undefined; if (state.status === 'completed' || state.status === 'error') { + const isStructuredOutput = + displayName.toLowerCase() === 'structuredoutput' || displayName.toLowerCase() === 'structured_output'; + const structuredPayload = + state.status === 'completed' + ? (() => { + const fromOutput = + typeof state.output === 'string' && state.output.trim().startsWith('{') + ? (() => { + try { + return JSON.parse(state.output) as unknown; + } catch { + return state.output; + } + })() + : state.output; + return fromOutput ?? enrichedArgs; + })() + : undefined; + return [ { type: 'tool_result', @@ -740,6 +785,16 @@ export class OpenCodeEventMapper { // Keep input on the result so UI embeds work when the call frame was skipped or dropped. ...(enrichedArgs !== undefined ? { args: enrichedArgs } : {}), ...('title' in state && typeof state.title === 'string' ? { title: state.title } : {}), + ...(isStructuredOutput && structuredPayload !== undefined + ? { + automationTurnStatus: + typeof structuredPayload === 'object' && + structuredPayload && + 'status' in (structuredPayload as object) + ? (structuredPayload as { status?: unknown }).status + : structuredPayload, + } + : {}), }, ]; } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-runtime.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-runtime.service.ts index bae8937c7..7ccff3d33 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-runtime.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-runtime.service.ts @@ -1,5 +1,15 @@ import { Injectable, Logger, Optional } from '@nestjs/common'; - +import { + AGENSTRA_AUTOMATION_AGENT_NAME, + AGENSTRA_AUTOMATION_TURN_STATUS_SCHEMA, + parseAgenstraAutomationTurnStatus, + type AgenstraAutomationTurnStatus, +} from '@forepath/agenstra/shared/util-opencode-config'; + +import { + isTicketAutomationLoopResumeSessionSuffix, + isTicketAutomationResumeSessionSuffix, +} from '../../constants/chat-session.constants'; import type { AgentProviderOptions, AgentResponseObject } from '../agent-provider.interface'; import { OutboundAgentEventPublisher } from '../outbound-agent-event-publisher'; @@ -253,6 +263,28 @@ export class OpenCodeRuntimeService { this.trackPendingInteraction(agentId, permissionId, sessionId, 'permission'); } + private autoReplyAutomationInteraction( + key: OpenCodeSessionKey, + questionId: string, + kind: PendingInteractionKind, + ): void { + const run = async (): Promise => { + try { + if (kind === 'permission') { + await this.replyPermission(key.agentId, key.containerId, questionId, 'always'); + } else { + await this.replyQuestion(key.agentId, key.containerId, questionId, undefined, true); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + this.logger.warn(`Automation auto-reply failed for ${kind} ${questionId} on agent ${key.agentId}: ${message}`); + } + }; + + void run(); + } + private async *promptAndDrain( key: OpenCodeSessionKey, message: string, @@ -265,6 +297,9 @@ export class OpenCodeRuntimeService { let aggregatedText = ''; let turnDone = false; let promptError: unknown | null = null; + let automationTurnStatus: AgenstraAutomationTurnStatus | undefined; + const automationSession = isTicketAutomationResumeSessionSuffix(key.resumeSessionSuffix); + const automationLoop = isTicketAutomationLoopResumeSessionSuffix(key.resumeSessionSuffix); const queue: AgentResponseObject[] = []; const notify = (() => { @@ -293,12 +328,40 @@ export class OpenCodeRuntimeService { subtype === 'permission' || subtype === 'permission.v2' ? 'permission' : 'question'; this.trackPendingInteraction(key.agentId, obj.questionId, obj.session_id, kind); + + if (automationSession) { + this.autoReplyAutomationInteraction(key, obj.questionId, kind); + } + } + + if (typeof obj.automationTurnStatus === 'string') { + const parsed = parseAgenstraAutomationTurnStatus(obj.automationTurnStatus); + + if (parsed) { + automationTurnStatus = parsed; + } + } + + if (obj.type === 'tool_result' && typeof obj.name === 'string') { + const toolName = obj.name.toLowerCase(); + + if (toolName === 'structuredoutput' || toolName === 'structured_output') { + const fromResult = parseAgenstraAutomationTurnStatus(obj.result); + const fromArgs = parseAgenstraAutomationTurnStatus(obj.args); + + automationTurnStatus = fromResult ?? fromArgs ?? automationTurnStatus; + } } if (obj.type === 'delta' && typeof obj.delta === 'string') { aggregatedText += obj.delta; } else if (obj.type === 'result' && typeof obj.result === 'string' && !obj.is_error) { aggregatedText = obj.result; + const fromResult = parseAgenstraAutomationTurnStatus(obj.result); + + if (fromResult) { + automationTurnStatus = fromResult; + } } if (obj.type === 'session_idle') { @@ -325,6 +388,8 @@ export class OpenCodeRuntimeService { })(); try { + // Implementation turns must not use format:json_schema — that constraint blocks tool use + // and collapses the whole turn into status-only text ("Structured output captured successfully."). const promptResult = await client.session.promptAsync({ path: { id: sessionId }, body: { @@ -334,6 +399,7 @@ export class OpenCodeRuntimeService { model: this.parseModelOption(options.model), } : {}), + ...(automationSession ? { agent: AGENSTRA_AUTOMATION_AGENT_NAME } : {}), }, }); @@ -370,8 +436,19 @@ export class OpenCodeRuntimeService { throw promptError; } - if (aggregatedText.trim()) { - const finalResult = this.eventMapper.buildFinalResult(aggregatedText, sessionId, toolState.lastUsage); + if (automationLoop && !automationTurnStatus) { + automationTurnStatus = await this.captureAutomationTurnStatus(key, sessionId, options); + } + + // Always emit a final result for automation loop turns so sync chat can settle even when + // the model produced only tool activity and status capture failed. + if (aggregatedText.trim() || automationTurnStatus || automationLoop) { + const finalResult = this.eventMapper.buildFinalResult( + aggregatedText, + sessionId, + toolState.lastUsage, + automationTurnStatus, + ); if (this.outboundPublisher) { void this.outboundPublisher.publish(key.agentId, finalResult); @@ -385,6 +462,137 @@ export class OpenCodeRuntimeService { } } + /** + * Follow-up constrained prompt: collect continue/complete without re-running implementation tools. + */ + private async captureAutomationTurnStatus( + key: OpenCodeSessionKey, + sessionId: string, + options?: AgentProviderOptions, + ): Promise { + const client = await this.clientFactory.getClient(key.agentId, key.containerId); + const abort = new AbortController(); + const toolState = createOpenCodeToolCallState(); + let turnDone = false; + let promptError: unknown | null = null; + let automationTurnStatus: AgenstraAutomationTurnStatus | undefined; + const consumer = (async () => { + try { + for await (const event of this.eventBridge.subscribe(key.agentId, key.containerId, sessionId, abort.signal)) { + const mapped = this.eventMapper.mapEvent(event, toolState, key.agentId); + + for (const obj of mapped) { + if (obj.type === 'question' && typeof obj.questionId === 'string' && typeof obj.session_id === 'string') { + const subtype = typeof obj.subtype === 'string' ? obj.subtype : ''; + const kind: PendingInteractionKind = + subtype === 'permission' || subtype === 'permission.v2' ? 'permission' : 'question'; + + this.trackPendingInteraction(key.agentId, obj.questionId, obj.session_id, kind); + this.autoReplyAutomationInteraction(key, obj.questionId, kind); + } + + if (typeof obj.automationTurnStatus === 'string') { + const parsed = parseAgenstraAutomationTurnStatus(obj.automationTurnStatus); + + if (parsed) { + automationTurnStatus = parsed; + } + } + + if (obj.type === 'tool_result' && typeof obj.name === 'string') { + const toolName = obj.name.toLowerCase(); + + if (toolName === 'structuredoutput' || toolName === 'structured_output') { + automationTurnStatus = + parseAgenstraAutomationTurnStatus(obj.result) ?? + parseAgenstraAutomationTurnStatus(obj.args) ?? + automationTurnStatus; + } + } + + if (obj.type === 'result' && typeof obj.result === 'string' && !obj.is_error) { + automationTurnStatus = parseAgenstraAutomationTurnStatus(obj.result) ?? automationTurnStatus; + } + + if (obj.type === 'session_idle') { + turnDone = true; + } + } + + if (turnDone) { + break; + } + } + } catch (error) { + if (!abort.signal.aborted) { + promptError = error; + turnDone = true; + } + } + })(); + + try { + const promptResult = await client.session.promptAsync({ + path: { id: sessionId }, + body: { + parts: [ + { + type: 'text', + text: + 'Report only the automation turn status for the work just performed. ' + + 'Use status "complete" if the scoped ticket work is implemented and ready for verification; ' + + 'otherwise use "continue". Do not make further code changes.', + }, + ], + ...(options?.model + ? { + model: this.parseModelOption(options.model), + } + : {}), + agent: AGENSTRA_AUTOMATION_AGENT_NAME, + format: { + type: 'json_schema' as const, + schema: { ...AGENSTRA_AUTOMATION_TURN_STATUS_SCHEMA } as Record, + retryCount: 2, + }, + }, + }); + + if (promptResult.error) { + const err = promptResult.error as { message?: string }; + + this.logger.warn( + `Automation turn-status capture failed for agent ${key.agentId}: ${err.message ?? 'Unknown prompt error'}`, + ); + + return undefined; + } + + const deadline = Date.now() + 120_000; + + while (!turnDone && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + if (promptError) { + this.logger.warn( + `Automation turn-status drain failed for agent ${key.agentId}: ${(promptError as Error).message ?? 'unknown'}`, + ); + } + + return automationTurnStatus; + } catch (error) { + const err = error as { message?: string }; + + this.logger.warn(`Automation turn-status capture error for agent ${key.agentId}: ${err.message ?? 'unknown'}`); + + return undefined; + } finally { + abort.abort(); + await consumer.catch(() => undefined); + } + } + private parseModelOption(model: string): { providerID: string; modelID: string } { const slash = model.indexOf('/'); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-sdk.types.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-sdk.types.ts index f15e20b37..9ab2875e7 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-sdk.types.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-sdk.types.ts @@ -391,13 +391,22 @@ export type RequestResult = { export type OpencodeClient = { session: { - create(options?: { body?: { parentID?: string; title?: string } }): Promise>; + create(options?: { + body?: { + parentID?: string; + title?: string; + agent?: string; + permission?: Array<{ permission: string; pattern: string; action: 'allow' | 'deny' | 'ask' }>; + }; + }): Promise>; get(options: { path: { id: string } }): Promise>; promptAsync(options: { path: { id: string }; body?: { parts: Array<{ type: 'text'; text: string }>; model?: { providerID: string; modelID: string }; + agent?: string; + format?: { type: 'text' } | { type: 'json_schema'; schema: Record; retryCount?: number }; }; }): Promise>; prompt(options: { @@ -405,6 +414,8 @@ export type OpencodeClient = { body?: { parts: Array<{ type: 'text'; text: string }>; model?: { providerID: string; modelID: string }; + agent?: string; + format?: { type: 'text' } | { type: 'json_schema'; schema: Record; retryCount?: number }; }; }): Promise>; }; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.spec.ts new file mode 100644 index 000000000..caa6d76dd --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.spec.ts @@ -0,0 +1,63 @@ +import { OpenCodeSessionService } from './opencode-session.service'; + +describe('OpenCodeSessionService', () => { + it('creates automation sessions with allow-all permission and platform agent', async () => { + const create = jest.fn().mockResolvedValue({ data: { id: 'ses_auto' } }); + const clientFactory = { + getClient: jest.fn().mockResolvedValue({ + session: { + get: jest.fn().mockRejectedValue(new Error('missing')), + create, + }, + }), + }; + const agentsRepository = { + findPersistedAcpSessionId: jest.fn().mockResolvedValue(null), + saveAcpSession: jest.fn().mockResolvedValue(undefined), + }; + const service = new OpenCodeSessionService(clientFactory as never, agentsRepository as never); + + await service.getOrCreateSessionId({ + agentId: 'agent-1', + containerId: 'ctr-1', + resumeSessionSuffix: '-ticket-auto-loop', + }); + + expect(create).toHaveBeenCalledWith({ + body: { + title: 'agenstra (-ticket-auto-loop)', + agent: 'agenstra-automation', + permission: [{ permission: '*', pattern: '*', action: 'allow' }], + }, + }); + }); + + it('does not attach automation permission override for interactive chat', async () => { + const create = jest.fn().mockResolvedValue({ data: { id: 'ses_chat' } }); + const clientFactory = { + getClient: jest.fn().mockResolvedValue({ + session: { + get: jest.fn().mockRejectedValue(new Error('missing')), + create, + }, + }), + }; + const agentsRepository = { + findPersistedAcpSessionId: jest.fn().mockResolvedValue(null), + saveAcpSession: jest.fn().mockResolvedValue(undefined), + }; + const service = new OpenCodeSessionService(clientFactory as never, agentsRepository as never); + + await service.getOrCreateSessionId({ + agentId: 'agent-1', + containerId: 'ctr-1', + resumeSessionSuffix: '-chat-abc', + }); + + expect(create).toHaveBeenCalledWith({ + body: { + title: 'agenstra (-chat-abc)', + }, + }); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.ts index c6e1a7bc5..aa340509e 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.ts @@ -1,5 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; +import { + AGENSTRA_AUTOMATION_AGENT_NAME, + AGENSTRA_AUTOMATION_SESSION_PERMISSION_RULESET, +} from '@forepath/agenstra/shared/util-opencode-config'; +import { isTicketAutomationResumeSessionSuffix } from '../../constants/chat-session.constants'; import { AgentsRepository } from '../../repositories/agents.repository'; import type { AgentProviderOptions } from '../agent-provider.interface'; @@ -62,9 +67,16 @@ export class OpenCodeSessionService { private async createSession(client: OpencodeClient, key: OpenCodeSessionKey): Promise { const titleSuffix = key.resumeSessionSuffix ? ` (${key.resumeSessionSuffix})` : ''; + const automation = isTicketAutomationResumeSessionSuffix(key.resumeSessionSuffix); const result = await client.session.create({ body: { title: `agenstra${titleSuffix}`, + ...(automation + ? { + agent: AGENSTRA_AUTOMATION_AGENT_NAME, + permission: [...AGENSTRA_AUTOMATION_SESSION_PERMISSION_RULESET], + } + : {}), }, }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts index 9313f625d..4ccfdcf87 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts @@ -332,9 +332,10 @@ describe('AgentsVcsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( mockContainerId, - shGitScriptContaining("git add -- 'file1.txt' 'file2.txt'"), + shGitScriptContaining("add -- 'file1.txt' 'file2.txt'"), undefined, false, + { user: 'agenstra' }, ); expect(mockGitStateBroadcast.notifyGitStateMayHaveChanged).toHaveBeenCalledWith(mockAgentId); }); @@ -348,9 +349,10 @@ describe('AgentsVcsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( mockContainerId, - shGitScriptContaining("git add -- 'src/my file.ts'"), + shGitScriptContaining("add -- 'src/my file.ts'"), undefined, false, + { user: 'agenstra' }, ); }); @@ -363,9 +365,10 @@ describe('AgentsVcsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( mockContainerId, - shGitScriptContaining('git add -A'), + shGitScriptContaining('add -A'), undefined, false, + { user: 'agenstra' }, ); }); }); @@ -382,9 +385,10 @@ describe('AgentsVcsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( mockContainerId, - shGitScriptContaining("git reset -- 'file1.txt'"), + shGitScriptContaining("reset -- 'file1.txt'"), undefined, false, + { user: 'agenstra' }, ); }); @@ -398,7 +402,7 @@ describe('AgentsVcsService', () => { const [, cmd] = dockerService.sendCommandToContainer.mock.calls[0]; const script = Array.isArray(cmd) ? cmd[2] : String(cmd); - expect(script).toMatch(/git reset$/); + expect(script).toMatch(/reset$/); expect(script).not.toContain('reset HEAD'); }); }); @@ -438,6 +442,7 @@ describe('AgentsVcsService', () => { agentsService.findOne.mockResolvedValue({} as any); agentsRepository.findByIdOrThrow.mockResolvedValue(mockAgentEntity); dockerService.sendCommandToContainer + .mockResolvedValueOnce('file:///origin.git') // hasOriginRemote .mockResolvedValueOnce('main') // rev-parse --abbrev-ref HEAD .mockResolvedValueOnce('') // ls-remote (no remote branch) .mockResolvedValueOnce(''); // push command @@ -446,9 +451,10 @@ describe('AgentsVcsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( mockContainerId, - shGitScriptContaining("git push -u origin 'main'"), + shGitScriptContaining("push -u origin 'main'"), undefined, - true, // checkExitCode=true + true, + { user: 'agenstra' }, ); }); @@ -456,6 +462,7 @@ describe('AgentsVcsService', () => { agentsService.findOne.mockResolvedValue({} as any); agentsRepository.findByIdOrThrow.mockResolvedValue(mockAgentEntity); dockerService.sendCommandToContainer + .mockResolvedValueOnce('file:///origin.git') // hasOriginRemote .mockResolvedValueOnce('main') // rev-parse --abbrev-ref HEAD .mockResolvedValueOnce('refs/heads/main') // ls-remote (remote branch exists) .mockResolvedValueOnce(''); // push command @@ -464,10 +471,26 @@ describe('AgentsVcsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( mockContainerId, - shGitScriptContaining("git push --force-with-lease origin 'main'"), + shGitScriptContaining("push --force-with-lease origin 'main'"), undefined, true, + { user: 'agenstra' }, + ); + }); + + it('should no-op when origin remote is missing', async () => { + agentsService.findOne.mockResolvedValue({} as any); + agentsRepository.findByIdOrThrow.mockResolvedValue(mockAgentEntity); + dockerService.sendCommandToContainer.mockResolvedValueOnce(''); // hasOriginRemote empty + + await service.push(mockAgentId); + + expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(1); + const scripts = dockerService.sendCommandToContainer.mock.calls.map((c) => + Array.isArray(c[1]) ? String(c[1][2] ?? '') : String(c[1]), ); + + expect(scripts.some((s) => s.includes('push'))).toBe(false); }); }); @@ -483,9 +506,10 @@ describe('AgentsVcsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( mockContainerId, - shGitScriptContaining('git pull'), + shGitScriptContaining('pull'), undefined, - true, // checkExitCode=true + true, + { user: 'agenstra' }, ); }); }); @@ -494,17 +518,30 @@ describe('AgentsVcsService', () => { it('should fetch changes from remote', async () => { agentsService.findOne.mockResolvedValue({} as any); agentsRepository.findByIdOrThrow.mockResolvedValue(mockAgentEntity); - dockerService.sendCommandToContainer.mockResolvedValue(''); + dockerService.sendCommandToContainer + .mockResolvedValueOnce('file:///origin.git') // hasOriginRemote + .mockResolvedValueOnce(''); // fetch await service.fetch(mockAgentId); expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( mockContainerId, - shGitScriptContaining('git fetch'), + shGitScriptContaining('fetch'), undefined, - true, // checkExitCode=true + true, + { user: 'agenstra' }, ); }); + + it('should no-op when origin remote is missing', async () => { + agentsService.findOne.mockResolvedValue({} as any); + agentsRepository.findByIdOrThrow.mockResolvedValue(mockAgentEntity); + dockerService.sendCommandToContainer.mockResolvedValueOnce(''); + + await service.fetch(mockAgentId); + + expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(1); + }); }); describe('createBranch', () => { @@ -526,6 +563,7 @@ describe('AgentsVcsService', () => { shGitScriptContaining('feat/new-feature'), undefined, false, + { user: 'agenstra' }, ); }); @@ -546,6 +584,7 @@ describe('AgentsVcsService', () => { shGitScriptContaining('custom-branch'), undefined, false, + { user: 'agenstra' }, ); }); }); @@ -562,9 +601,10 @@ describe('AgentsVcsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( mockContainerId, - shGitScriptContaining(`git checkout '${branchName}'`), + shGitScriptContaining(`checkout '${branchName}'`), undefined, false, + { user: 'agenstra' }, ); }); }); @@ -583,9 +623,10 @@ describe('AgentsVcsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( mockContainerId, - shGitScriptContaining(`git branch -D '${branchName}'`), + shGitScriptContaining(`branch -D '${branchName}'`), undefined, false, + { user: 'agenstra' }, ); }); }); @@ -602,9 +643,10 @@ describe('AgentsVcsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( mockContainerId, - shGitScriptContaining(`git rebase '${branchName}'`), + shGitScriptContaining(`rebase '${branchName}'`), undefined, false, + { user: 'agenstra' }, ); }); }); @@ -631,6 +673,7 @@ describe('AgentsVcsService', () => { shGitScriptContaining('--theirs'), undefined, false, + { user: 'agenstra' }, ); }); @@ -655,23 +698,58 @@ describe('AgentsVcsService', () => { shGitScriptContaining('--ours'), undefined, false, + { user: 'agenstra' }, ); }); }); describe('prepareCleanWorkspace', () => { - it('should fetch, checkout, reset, and clean', async () => { + it('should fetch, checkout, reset, and clean when origin exists', async () => { agentsService.findOne.mockResolvedValue({} as any); agentsRepository.findByIdOrThrow.mockResolvedValue(mockAgentEntity); dockerService.sendCommandToContainer - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - .mockResolvedValueOnce(''); + .mockResolvedValueOnce('true') // ensureGitRepository + .mockResolvedValueOnce('file:///origin.git') // hasOriginRemote + .mockResolvedValueOnce('') // fetch origin + .mockResolvedValueOnce('abc123') // origin/main exists + .mockResolvedValueOnce('') // checkout -B + .mockResolvedValueOnce('') // reset --hard + .mockResolvedValueOnce(''); // clean -fd await service.prepareCleanWorkspace(mockAgentId, 'main'); - expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(4); + expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(7); + expect(dockerService.sendCommandToContainer).toHaveBeenCalledWith( + mockContainerId, + expect.any(Array), + undefined, + expect.any(Boolean), + { user: 'agenstra' }, + ); + }); + + it('should initialize a local branch without calling fetch when origin is missing', async () => { + agentsService.findOne.mockResolvedValue({} as any); + agentsRepository.findByIdOrThrow.mockResolvedValue(mockAgentEntity); + dockerService.sendCommandToContainer + .mockResolvedValueOnce('true') // ensureGitRepository + .mockResolvedValueOnce('') // hasOriginRemote — no origin + .mockResolvedValueOnce('') // rev-parse HEAD (unborn) + .mockResolvedValueOnce('') // checkout -B + .mockResolvedValueOnce('') // empty commit + .mockResolvedValueOnce('') // checkout + .mockResolvedValueOnce('') // reset --hard HEAD + .mockResolvedValueOnce(''); // clean -fd + + await service.prepareCleanWorkspace(mockAgentId, 'main'); + + const scripts = dockerService.sendCommandToContainer.mock.calls.map((c) => + Array.isArray(c[1]) ? String(c[1][2] ?? '') : String(c[1]), + ); + + expect(scripts.some((s) => s.includes('fetch origin'))).toBe(false); + expect(scripts.some((s) => s.includes('reset --hard origin/'))).toBe(false); + expect(scripts.some((s) => s.includes('reset --hard HEAD'))).toBe(true); }); it('should reject invalid branch names', async () => { diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts index 733ccc391..64ea5d20f 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts @@ -97,7 +97,8 @@ export class AgentsVcsService { } /** - * Execute a git command in the agent's container. + * Execute a git command in the agent's container as the runtime user (`agenstra`). + * Marks `/app` safe in-process so root-created `.git` metadata does not trip dubious-ownership checks. * @param containerId - The container ID * @param command - Git command (without 'git' prefix) * @param workingDir - Working directory (defaults to BASE_PATH) @@ -127,17 +128,88 @@ export class AgentsVcsService { // Pass argv directly so parseShellCommand cannot eat escapes or split on spaces // inside the -c script (e.g. paths like `src/my file.ts`). const safeWorkingDir = workingDir.replace(/'/g, `'\\''`); - const script = `cd '${safeWorkingDir}' && ${envPrefix}git ${command}`; + // Runtime processes use agenstra; manager historically exec'd as root and hit "dubious ownership". + const script = + `cd '${safeWorkingDir}' && ${envPrefix}` + `git -c safe.directory=/app -c safe.directory='*' ${command}`; const output = await this.dockerService.sendCommandToContainer( containerId, ['sh', '-c', script], undefined, checkExitCode, + { user: 'agenstra' }, ); return this.cleanOutput(output, !preserveLeadingSpaces); } + /** True when `origin` is configured (empty workspaces have no remote). */ + private async hasOriginRemote(containerId: string): Promise { + try { + const url = await this.executeGitCommand( + containerId, + 'remote get-url origin 2>/dev/null || echo ""', + this.BASE_PATH, + false, + false, + false, + ); + + return url.trim().length > 0; + } catch { + return false; + } + } + + /** Ensure `/app` is a git repo (empty environment may only have an unborn init, or none). */ + private async ensureGitRepository(containerId: string): Promise { + const inside = await this.executeGitCommand( + containerId, + 'rev-parse --is-inside-work-tree 2>/dev/null || echo "false"', + this.BASE_PATH, + false, + false, + false, + ); + + if (inside.trim() === 'true') { + return; + } + + await this.executeGitCommand(containerId, 'init', this.BASE_PATH, false, false, true); + } + + /** + * Ensure `baseBranch` exists locally with at least one commit (empty / local-only workspaces). + */ + private async ensureLocalBranchWithCommit(containerId: string, escapedBranch: string): Promise { + const headOk = await this.executeGitCommand( + containerId, + 'rev-parse --verify HEAD 2>/dev/null || echo ""', + this.BASE_PATH, + false, + false, + false, + ); + + if (!headOk.trim()) { + await this.executeGitCommand(containerId, `checkout -B ${escapedBranch}`, this.BASE_PATH, false, false, true); + await this.executeGitCommand( + containerId, + `-c user.name='${this.commitAuthorName.replace(/'/g, `'\\''`)}' ` + + `-c user.email='${this.commitAuthorEmail.replace(/'/g, `'\\''`)}' ` + + `commit --allow-empty -m 'Initial commit'`, + this.BASE_PATH, + false, + false, + true, + ); + + return; + } + + await this.executeGitCommand(containerId, `checkout -B ${escapedBranch}`, this.BASE_PATH, false, false, true); + } + /** * Get git status for the agent's repository. * @param agentId - The UUID of the agent @@ -871,6 +943,14 @@ export class AgentsVcsService { } try { + // Empty / local-only workspaces have no origin — skip push rather than failing automation finalize. + if (!(await this.hasOriginRemote(agentEntity.containerId))) { + this.logger.log(`Skipping push for agent ${agentId}: no origin remote`); + this.notifyGitStateMayHaveChanged(agentId); + + return; + } + // Get current branch const currentBranchOutput = await this.executeGitCommand(agentEntity.containerId, 'rev-parse --abbrev-ref HEAD'); const currentBranch = this.cleanBranchName(currentBranchOutput); @@ -989,6 +1069,14 @@ export class AgentsVcsService { } try { + // Empty / local-only workspaces have no origin — treat as no-op. + if (!(await this.hasOriginRemote(agentEntity.containerId))) { + this.logger.debug(`Skipping fetch for agent ${agentId}: no origin remote`); + this.notifyGitStateMayHaveChanged(agentId); + + return; + } + // Execute fetch with disablePrompts=true to prevent interactive credential prompts // Also enable exit code checking to properly detect and report fetch failures await this.executeGitCommand(agentEntity.containerId, 'fetch origin', this.BASE_PATH, false, true, true); @@ -1204,8 +1292,9 @@ export class AgentsVcsService { } /** - * Fetch, checkout `baseBranch`, hard-reset to `origin/baseBranch`, and clean untracked files. - * Intended for autonomous ticket orchestration after access checks upstream. + * Prepare a clean working tree on `baseBranch` for autonomous ticket orchestration. + * With `origin`: fetch + hard-reset to `origin/baseBranch`. + * Without `origin` (empty / local-only workspace): ensure a local branch + commit, reset to HEAD. */ async prepareCleanWorkspace(agentId: string, baseBranch: string): Promise { await this.agentsService.findOne(agentId); @@ -1225,9 +1314,48 @@ export class AgentsVcsService { const escaped = this.escapePath(b); try { - await this.executeGitCommand(containerId, 'fetch origin', this.BASE_PATH, false, true, true); - await this.executeGitCommand(containerId, `checkout ${escaped}`, this.BASE_PATH, false, true, true); - await this.executeGitCommand(containerId, `reset --hard origin/${escaped}`, this.BASE_PATH, false, true, true); + await this.ensureGitRepository(containerId); + const hasOrigin = await this.hasOriginRemote(containerId); + + if (hasOrigin) { + await this.executeGitCommand(containerId, 'fetch origin', this.BASE_PATH, false, true, true); + const remoteRef = await this.executeGitCommand( + containerId, + `rev-parse --verify origin/${escaped} 2>/dev/null || echo ""`, + this.BASE_PATH, + false, + false, + false, + ); + + if (remoteRef.trim()) { + await this.executeGitCommand( + containerId, + `checkout -B ${escaped} origin/${escaped}`, + this.BASE_PATH, + false, + true, + true, + ); + await this.executeGitCommand( + containerId, + `reset --hard origin/${escaped}`, + this.BASE_PATH, + false, + true, + true, + ); + } else { + await this.ensureLocalBranchWithCommit(containerId, escaped); + await this.executeGitCommand(containerId, `checkout ${escaped}`, this.BASE_PATH, false, true, true); + await this.executeGitCommand(containerId, 'reset --hard HEAD', this.BASE_PATH, false, true, true); + } + } else { + await this.ensureLocalBranchWithCommit(containerId, escaped); + await this.executeGitCommand(containerId, `checkout ${escaped}`, this.BASE_PATH, false, true, true); + await this.executeGitCommand(containerId, 'reset --hard HEAD', this.BASE_PATH, false, true, true); + } + await this.executeGitCommand(containerId, 'clean -fd', this.BASE_PATH, false, true, true); this.notifyGitStateMayHaveChanged(agentId); this.notifyWorkspaceRebuild(agentId, 'git-prepare-clean'); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts index 4ea403f16..4937fb1f8 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts @@ -172,7 +172,7 @@ describe('TicketsService', () => { finishedAt: null, updatedAt: '2024-01-01T00:00:00Z', iterationCount: 0, - completionMarkerSeen: false, + completionSignalSeen: false, verificationPassed: null, failureCode: null, summary: null, diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.spec.ts index 52a176039..b7fc1e428 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.spec.ts @@ -47,6 +47,7 @@ describe('TicketAutomationEffects', () => { ticketId: 't1', eligible: false, allowedAgentIds: [], + preferredModel: null, includeWorkspaceContext: true, contextEnvironmentIds: [], autoEnrichmentEnabled: true, diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.spec.ts index da527bffc..82997f611 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.spec.ts @@ -34,6 +34,7 @@ describe('ticketAutomationReducer', () => { ticketId: 't1', eligible: true, allowedAgentIds: ['a1'], + preferredModel: 'opencode/test-model', includeWorkspaceContext: true, contextEnvironmentIds: [], autoEnrichmentEnabled: true, @@ -65,7 +66,7 @@ describe('ticketAutomationReducer', () => { finishedAt: null, updatedAt: '2024-01-01T00:00:00Z', iterationCount: 1, - completionMarkerSeen: false, + completionSignalSeen: false, verificationPassed: null, failureCode: null, summary: null, diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.types.ts index 11c0d4674..28fc29f2b 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.types.ts @@ -8,6 +8,7 @@ export type TicketAutomationBranchStrategy = 'reuse_per_ticket' | 'new_per_run'; export interface UpdateTicketAutomationDto { eligible?: boolean; allowedAgentIds?: string[]; + preferredModel?: string | null; includeWorkspaceContext?: boolean; contextEnvironmentIds?: string[]; autoEnrichmentEnabled?: boolean; @@ -33,6 +34,7 @@ export interface TicketAutomationResponseDto { ticketId: string; eligible: boolean; allowedAgentIds: string[]; + preferredModel: string | null; includeWorkspaceContext: boolean; contextEnvironmentIds: string[]; autoEnrichmentEnabled: boolean; @@ -75,7 +77,7 @@ export interface TicketAutomationRunResponseDto { finishedAt: string | null; updatedAt: string; iterationCount: number; - completionMarkerSeen: boolean; + completionSignalSeen: boolean; verificationPassed: boolean | null; failureCode: string | null; summary: Record | null; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts index 39b04723f..175d0f0c3 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts @@ -480,6 +480,7 @@ describe('ticketsReducer', () => { ticketId: 'ticket-1', eligible: true, allowedAgentIds: [] as string[], + preferredModel: null as string | null, includeWorkspaceContext: true, contextEnvironmentIds: [] as string[], autoEnrichmentEnabled: true, diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts index 6702e428c..bc76cdf16 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/tickets/tickets.types.ts @@ -44,6 +44,8 @@ export interface TicketResponseDto { createdByEmail?: string | null; /** Preferred workspace agent for chat/AI when viewing this ticket. */ preferredChatAgentId?: string | null; + /** Preferred OpenCode `provider/model` for chat/AI on this ticket. */ + preferredChatModel?: string | null; /** True when autonomous prototyping is enabled for this ticket. */ automationEligible: boolean; createdAt: string; @@ -77,6 +79,7 @@ export interface UpdateTicketDto { priority?: TicketPriority; status?: TicketStatus; preferredChatAgentId?: string | null; + preferredChatModel?: string | null; } export interface MigrateTicketDto { diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/utils/ticket-automation-chat-run-mapper.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/utils/ticket-automation-chat-run-mapper.ts index 83ad1ad3a..cc84c9fe9 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/utils/ticket-automation-chat-run-mapper.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/utils/ticket-automation-chat-run-mapper.ts @@ -24,7 +24,7 @@ export function ticketAutomationRunChatSummaryToResponseDto( finishedAt: summary.finishedAt, updatedAt: summary.updatedAt, iterationCount: summary.iterationCount ?? 0, - completionMarkerSeen: false, + completionSignalSeen: false, verificationPassed: null, failureCode: null, summary: null, diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-automation-card-merge.spec.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-automation-card-merge.spec.ts index e09dceaee..60d61be9d 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-automation-card-merge.spec.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-automation-card-merge.spec.ts @@ -70,7 +70,7 @@ describe('mergeTicketAutomationChatCardPayload', () => { finishedAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T12:00:00Z', iterationCount: 2, - completionMarkerSeen: true, + completionSignalSeen: true, verificationPassed: true, failureCode: null, summary: null, diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/ticket-automation-run-labels.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/ticket-automation-run-labels.ts index 1eb486cd8..d0682c47b 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/ticket-automation-run-labels.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/ticket-automation-run-labels.ts @@ -70,10 +70,12 @@ export function ticketAutomationFailureCodeLabel(code: string): string { return $localize`:@@featureTicketsBoard-runFailureVcsBranchExists:Branch already exists`; case 'agent_provider_error': return $localize`:@@featureTicketsBoard-runFailureAgentProviderError:Agent provider error`; + case 'agent_no_completion_status': + return $localize`:@@featureTicketsBoard-runFailureAgentNoCompletionStatus:No completion status`; case 'agent_no_completion_marker': return $localize`:@@featureTicketsBoard-runFailureAgentNoCompletionMarker:No completion marker`; case 'marker_without_verify': - return $localize`:@@featureTicketsBoard-runFailureMarkerWithoutVerify:Completion marker without verify profile`; + return $localize`:@@featureTicketsBoard-runFailureMarkerWithoutVerify:Completion without verify profile`; case 'verify_command_failed': return $localize`:@@featureTicketsBoard-runFailureVerifyCommandFailed:Verify command failed`; case 'commit_failed': diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html index a9f814e5e..054228bd3 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.html @@ -392,6 +392,30 @@

{{ pageTitle }}

placeholder="Select agent…" /> +
+ Model for chat / AI +
+
+ + @if (ticketAiModelOptions().length === 0) { + + } + @for (model of ticketAiModelOptions(); track model.value) { + + } + +
@let total = d.tasks.open + d.tasks.children.open + d.tasks.done + d.tasks.children.done; @if (total > 0) {
@@ -831,6 +855,36 @@

} +
+ Model for automation +

+ Required when automation is enabled. OpenCode has no auto model selection. +

+ + @if (ticketAiModelOptions().length === 0) { + + } + @for (model of ticketAiModelOptions(); track model.value) { + + } + +
('draft'); selectedAgentForAi = signal(null); + /** OpenCode `provider/model` for chat/AI on the open ticket. */ + selectedModelForAi = signal(null); + /** Model dropdown options for the selected chat agent (from GET …/models). */ + ticketAiModelOptions = signal>([]); + ticketAiModelsLoading = signal(false); newCommentText = signal(''); prototypeError = signal(null); bodyGenError = signal(null); @@ -606,6 +615,8 @@ export class TicketsBoardComponent implements OnInit { automationDraftRequiresApproval = signal(false); /** Sorted unique agent UUIDs allowed to run automation for this ticket. */ automationDraftAllowedAgentIds = signal([]); + /** OpenCode `provider/model` for autonomous runs. */ + automationDraftPreferredModel = signal(null); automationDraftIncludeWorkspaceContext = signal(true); automationDraftAutoEnrichmentEnabled = signal(true); automationDraftContextEnvironmentIds = signal([]); @@ -783,6 +794,8 @@ export class TicketsBoardComponent implements OnInit { this.chatAgentForAiSyncTicketId = null; this.chatAgentForAiLastSyncedDetailUpdatedAt = null; this.selectedAgentForAi.set(null); + this.selectedModelForAi.set(null); + this.ticketAiModelOptions.set([]); return; } @@ -791,6 +804,7 @@ export class TicketsBoardComponent implements OnInit { this.chatAgentForAiSyncTicketId = d.id; this.chatAgentForAiLastSyncedDetailUpdatedAt = null; this.selectedAgentForAi.set(null); + this.selectedModelForAi.set(d.preferredChatModel ?? null); } if (chatAgents.length === 0 && this.chatAgentForAiLastSyncedDetailUpdatedAt === null) { @@ -808,15 +822,72 @@ export class TicketsBoardComponent implements OnInit { if (revBumped) { this.chatAgentForAiLastSyncedDetailUpdatedAt = rev; + this.selectedModelForAi.set(d.preferredChatModel ?? this.selectedModelForAi()); } this.selectedAgentForAi.set(pick); queueMicrotask(() => { this.ensureChatAgentInAutomationAllowedList(); this.ensureChatAgentInAutomationContextEnvironmentList(); + this.ensureChatModelInAutomationPreferredModel(); }); }); + effect(() => { + const clientId = this.effectiveClientId(); + const agentId = this.selectedAgentForAi(); + + if (!clientId || !agentId) { + this.ticketAiModelOptions.set([]); + this.ticketAiModelsLoading.set(false); + + return; + } + + this.ticketAiModelsLoading.set(true); + this.agentsFacade.loadClientAgentModels(clientId, agentId); + }); + + combineLatest([toObservable(this.effectiveClientId), toObservable(this.selectedAgentForAi)]) + .pipe( + switchMap(([clientId, agentId]) => { + if (!clientId || !agentId) { + return of({ models: null as Record | null, loading: false, error: null as string | null }); + } + + return combineLatest([ + this.agentsFacade.getClientAgentModels$(clientId, agentId), + this.agentsFacade.getClientAgentModelsLoading$(clientId, agentId), + this.agentsFacade.getClientAgentModelsError$(clientId, agentId), + ]).pipe(map(([models, loading, error]) => ({ models, loading, error }))); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(({ models, loading, error }) => { + this.ticketAiModelsLoading.set(loading || (models === null && error === null)); + const opts = models ? Object.entries(models).map(([value, label]) => ({ value, label })) : []; + + this.ticketAiModelOptions.set(opts); + const current = this.selectedModelForAi(); + + if (opts.length === 0) { + return; + } + + if (!current || !opts.some((o) => o.value === current)) { + const next = opts[0]?.value ?? null; + + this.selectedModelForAi.set(next); + const detail = this.detail(); + + if (detail && next && next !== (detail.preferredChatModel ?? null)) { + this.ticketsFacade.update(detail.id, { preferredChatModel: next }); + } + + queueMicrotask(() => this.ensureChatModelInAutomationPreferredModel()); + } + }); + effect(() => { const d = this.detail()?.id; @@ -856,6 +927,7 @@ export class TicketsBoardComponent implements OnInit { this.automationDraftRequiresApproval.set(cfg.requiresApproval); this.automationDraftAllowedAgentIds.set(normalizeAllowedAgentIdList(cfg.allowedAgentIds)); + this.automationDraftPreferredModel.set(cfg.preferredModel ?? null); this.automationDraftIncludeWorkspaceContext.set(cfg.includeWorkspaceContext !== false); this.automationDraftAutoEnrichmentEnabled.set(cfg.autoEnrichmentEnabled !== false); this.automationDraftContextEnvironmentIds.set(normalizeAllowedAgentIdList(cfg.contextEnvironmentIds ?? [])); @@ -870,6 +942,7 @@ export class TicketsBoardComponent implements OnInit { queueMicrotask(() => { this.ensureChatAgentInAutomationAllowedList(); this.ensureChatAgentInAutomationContextEnvironmentList(); + this.ensureChatModelInAutomationPreferredModel(); }); }); @@ -878,6 +951,7 @@ export class TicketsBoardComponent implements OnInit { const cfg = this.ticketAutomationConfig(); this.selectedAgentForAi(); + this.selectedModelForAi(); this.automationTicketAutomationAgentChoices(); this.autonomyEnabledAgentIds(); @@ -892,6 +966,7 @@ export class TicketsBoardComponent implements OnInit { queueMicrotask(() => { this.ensureChatAgentInAutomationAllowedList(); this.ensureChatAgentInAutomationContextEnvironmentList(); + this.ensureChatModelInAutomationPreferredModel(); }); }); @@ -1812,6 +1887,44 @@ export class TicketsBoardComponent implements OnInit { this.ticketsFacade.update(ticket.id, { preferredChatAgentId: agentId }); } + onPreferredChatModelChange(ticket: TicketResponseDto, model: string | null): void { + const next = model?.trim() ? model.trim() : null; + + this.selectedModelForAi.set(next); + const current = ticket.preferredChatModel ?? null; + + if (next === current) { + queueMicrotask(() => this.ensureChatModelInAutomationPreferredModel()); + + return; + } + + this.ticketsFacade.update(ticket.id, { preferredChatModel: next }); + queueMicrotask(() => this.ensureChatModelInAutomationPreferredModel()); + } + + onAutomationPreferredModelChange(model: string | null): void { + const next = model?.trim() ? model.trim() : null; + + this.automationDraftPreferredModel.set(next); + } + + /** + * When chat has a model selected, keep automation preferredModel aligned so enabling automation + * does not leave eligible=true without a model (OpenCode has no auto). + */ + private ensureChatModelInAutomationPreferredModel(): void { + const chatModel = this.selectedModelForAi(); + + if (!chatModel) { + return; + } + + if (!this.automationDraftPreferredModel()) { + this.automationDraftPreferredModel.set(chatModel); + } + } + showCreateSubtaskModal(): void { const parentId = this.detail()?.id; @@ -2097,6 +2210,7 @@ export class TicketsBoardComponent implements OnInit { eligible: this.automationDraftEligible(), requiresApproval: this.automationDraftRequiresApproval(), allowedAgentIds: this.automationDraftAllowedAgentIds(), + preferredModel: this.automationDraftPreferredModel(), includeWorkspaceContext: this.automationDraftIncludeWorkspaceContext(), autoEnrichmentEnabled: this.automationDraftAutoEnrichmentEnabled(), contextEnvironmentIds: this.automationDraftContextEnvironmentIds(), @@ -2807,7 +2921,7 @@ export class TicketsBoardComponent implements OnInit { d.title, agentId, correlationId, - undefined, + this.selectedModelForAi() ?? undefined, hierarchyContext || undefined, ); }), diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.spec.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.spec.ts index 0239dc5a6..6eacc4762 100644 --- a/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.spec.ts +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.spec.ts @@ -581,9 +581,18 @@ describe('materializeModelAllowDeny', () => { }); expect(prepared.skills).toEqual({ - paths: ['/opt/skills/local'], + paths: [ + '/opt/skills/local', + '/opt/agenstra/skills/agenstra-ticket-automation', + '.opencode/skills/agenstra-ticket-automation', + ], urls: ['https://example.com/skill'], }); + expect((prepared.agent as Record)['agenstra-automation']).toMatchObject({ + mode: 'primary', + hidden: true, + permission: 'allow', + }); expect(prepared.mcp).toEqual({ docs: { type: 'local', command: ['echo'], timeout: 1500, enabled: false }, remote: { @@ -768,6 +777,12 @@ describe('prepareConfigForSync wire mapping', () => { expect(prepared.plugin).toEqual(['@pkg/a']); expect(prepared.agent).toEqual({ build: { description: 'Build', prompt: 'You build', disable: true, mode: 'primary' }, + 'agenstra-automation': { + description: 'Unattended Agenstra ticket automation (platform-managed)', + mode: 'primary', + hidden: true, + permission: 'allow', + }, }); expect(prepared.command).toEqual({ hello: { template: 'hi', subtask: true }, diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts index 4dcbe46bb..15ce5cb33 100644 --- a/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts @@ -7,3 +7,4 @@ export * from './mcp-wire'; export * from './credentials'; export * from './materialize'; export * from './enforce-allow-deny'; +export * from './automation-platform'; diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/automation-platform.spec.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/automation-platform.spec.ts new file mode 100644 index 000000000..e4a87106d --- /dev/null +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/automation-platform.spec.ts @@ -0,0 +1,46 @@ +import { + AGENSTRA_AUTOMATION_AGENT_NAME, + AGENSTRA_OPENCODE_PLATFORM_WIRE_VERSION, + AGENSTRA_TICKET_AUTOMATION_SKILL_ABS_DIR, + AGENSTRA_TICKET_AUTOMATION_SKILL_REL_DIR, + injectPlatformAutomationConfig, + parseAgenstraAutomationTurnStatus, +} from './automation-platform'; + +describe('automation-platform', () => { + it('exports a positive platform wire version for sync revision hashing', () => { + expect(AGENSTRA_OPENCODE_PLATFORM_WIRE_VERSION).toBeGreaterThan(0); + }); + + it('injects platform agent and skill paths even when overlays omit them', () => { + const injected = injectPlatformAutomationConfig({ + agents: { build: { mode: 'primary' } }, + skills: ['/opt/skills/local'], + }); + + expect(injected.agents).toMatchObject({ + build: { mode: 'primary' }, + [AGENSTRA_AUTOMATION_AGENT_NAME]: { + mode: 'primary', + hidden: true, + permission: 'allow', + }, + }); + expect(injected.skills).toEqual({ + paths: ['/opt/skills/local', AGENSTRA_TICKET_AUTOMATION_SKILL_ABS_DIR, AGENSTRA_TICKET_AUTOMATION_SKILL_REL_DIR], + urls: [], + }); + }); + + it('re-injects platform agent when overlay tried to remove it', () => { + const injected = injectPlatformAutomationConfig({ agents: {} }); + + expect(injected.agents).toHaveProperty(AGENSTRA_AUTOMATION_AGENT_NAME); + }); + + it('parses structured turn status payloads', () => { + expect(parseAgenstraAutomationTurnStatus({ status: 'complete' })).toBe('complete'); + expect(parseAgenstraAutomationTurnStatus('{"status":"continue"}')).toBe('continue'); + expect(parseAgenstraAutomationTurnStatus({ status: 'nope' })).toBeUndefined(); + }); +}); diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/automation-platform.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/automation-platform.ts new file mode 100644 index 000000000..26a233e69 --- /dev/null +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/automation-platform.ts @@ -0,0 +1,174 @@ +import type { JsonObject } from './types'; + +/** + * Bump when {@link injectPlatformAutomationConfig} / prepareConfigForSync platform wire changes. + * Included in OpenCode sync revision hashes so existing agents re-sync after deploys. + */ +export const AGENSTRA_OPENCODE_PLATFORM_WIRE_VERSION = 2; + +/** OpenCode primary agent used exclusively for Agenstra ticket automation sessions. */ +export const AGENSTRA_AUTOMATION_AGENT_NAME = 'agenstra-automation'; + +/** Platform skill that documents structured turn-status protocol for ticket runs. */ +export const AGENSTRA_TICKET_AUTOMATION_SKILL_NAME = 'agenstra-ticket-automation'; + +/** + * Absolute skill path installed on workers (root-owned layer emit / sync). + * Relative discovery under `.opencode/skills` is also listed for OpenCode path concat. + */ +export const AGENSTRA_TICKET_AUTOMATION_SKILL_ABS_DIR = '/opt/agenstra/skills/agenstra-ticket-automation'; + +export const AGENSTRA_TICKET_AUTOMATION_SKILL_REL_DIR = '.opencode/skills/agenstra-ticket-automation'; + +/** Session-scoped OpenCode permission ruleset: allow every action for automation sessions. */ +export const AGENSTRA_AUTOMATION_SESSION_PERMISSION_RULESET = [ + { permission: '*', pattern: '*', action: 'allow' as const }, +]; + +/** Structured turn-status schema for OpenCode `format: json_schema` on automation loop prompts. */ +export const AGENSTRA_AUTOMATION_TURN_STATUS_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + status: { + type: 'string', + enum: ['continue', 'complete'], + description: 'continue = more implementation work remains; complete = ready for verification', + }, + summary: { + type: 'string', + description: 'Short summary of what was done this turn or why work is complete', + }, + }, + required: ['status'], +} as const; + +export type AgenstraAutomationTurnStatus = 'continue' | 'complete'; + +export function isAgenstraAutomationTurnStatus(value: unknown): value is AgenstraAutomationTurnStatus { + return value === 'continue' || value === 'complete'; +} + +/** + * Parse structured turn status from OpenCode structured output, tool payloads, or JSON text. + */ +export function parseAgenstraAutomationTurnStatus(payload: unknown): AgenstraAutomationTurnStatus | undefined { + if (isAgenstraAutomationTurnStatus(payload)) { + return payload; + } + + if (typeof payload === 'string') { + const trimmed = payload.trim(); + + if (isAgenstraAutomationTurnStatus(trimmed)) { + return trimmed; + } + + try { + return parseAgenstraAutomationTurnStatus(JSON.parse(trimmed) as unknown); + } catch { + return undefined; + } + } + + if (!payload || typeof payload !== 'object') { + return undefined; + } + + const record = payload as Record; + + if (isAgenstraAutomationTurnStatus(record['status'])) { + return record['status']; + } + + return undefined; +} + +export const AGENSTRA_TICKET_AUTOMATION_SKILL_MD = `--- +name: agenstra-ticket-automation +description: Structured turn-status protocol for Agenstra autonomous ticket prototyping runs +compatibility: opencode +metadata: + audience: automation + workflow: ticket-run +--- + +# Agenstra ticket automation + +You are running an unattended ticket automation session. There is no human to approve tools or answer questions. + +## Turn status (required) + +After each implementation turn, Agenstra collects structured output: + +- \`status: "continue"\` — more code changes or investigation are still needed +- \`status: "complete"\` — the scoped prototype is ready for automated verification + +Set \`complete\` only when the requested ticket work is implemented and ready for verifier commands / commit. +Do not ask the user questions; proceed with reasonable defaults. Prefer finishing the scoped work in as few turns as possible. +`; + +/** + * Inject platform automation agent + skill paths into an effective V2 overlay before wire sync. + * Always applied so UI overlays cannot remove the automation agent or skill discovery path. + */ +export function injectPlatformAutomationConfig(config: JsonObject): JsonObject { + const next: JsonObject = { ...config }; + const agents = isPlainObject(next['agents']) ? { ...(next['agents'] as JsonObject) } : {}; + + agents[AGENSTRA_AUTOMATION_AGENT_NAME] = { + description: 'Unattended Agenstra ticket automation (platform-managed)', + mode: 'primary', + hidden: true, + permission: 'allow', + }; + next['agents'] = agents; + + const skills = normalizeSkillsRoot(next['skills']); + const paths = new Set(skills.paths); + paths.add(AGENSTRA_TICKET_AUTOMATION_SKILL_ABS_DIR); + paths.add(AGENSTRA_TICKET_AUTOMATION_SKILL_REL_DIR); + next['skills'] = { paths: [...paths], urls: skills.urls }; + + return next; +} + +function isPlainObject(value: unknown): value is JsonObject { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeSkillsRoot(value: unknown): { paths: string[]; urls: string[] } { + if (Array.isArray(value)) { + const paths: string[] = []; + const urls: string[] = []; + + for (const entry of value) { + if (typeof entry !== 'string' || !entry.trim()) { + continue; + } + + const trimmed = entry.trim(); + + if (/^https?:\/\//i.test(trimmed)) { + urls.push(trimmed); + } else { + paths.push(trimmed); + } + } + + return { paths, urls }; + } + + if (!isPlainObject(value)) { + return { paths: [], urls: [] }; + } + + const paths = Array.isArray(value['paths']) + ? value['paths'].filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + : []; + const urls = Array.isArray(value['urls']) + ? value['urls'].filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + : []; + + return { paths, urls }; +} diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/materialize.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/materialize.ts index 4c4e89220..24c604421 100644 --- a/libs/domains/agenstra/shared/util-opencode-config/src/lib/materialize.ts +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/materialize.ts @@ -2,6 +2,7 @@ import type { JsonObject } from './types'; import { migrateConfigV1ToV2 } from './migrate-v1-to-v2'; import { wireMcpOauthForOpenCode } from './mcp-wire'; import { isMcpServerAllowed, resolveMcpAllowDenyIdentity } from '@forepath/agenstra/shared/util-opencode-mcp-servers'; +import { injectPlatformAutomationConfig } from './automation-platform'; /** Platform / UI-only roots that OpenCode Config rejects or silently drops. */ const UNSUPPORTED_WIRE_ROOTS = [ @@ -220,10 +221,14 @@ function wireAgentEntry(agent: JsonObject): JsonObject { delete next['disabled']; if ('permissions' in next && !('permission' in next)) { - const permission = permissionsRulesToWire(next['permissions']); + if (typeof next['permissions'] === 'string') { + next['permission'] = next['permissions']; + } else { + const permission = permissionsRulesToWire(next['permissions']); - if (permission) { - next['permission'] = permission; + if (permission) { + next['permission'] = permission; + } } } @@ -870,10 +875,15 @@ export function toOpencodeWireConfig(input: JsonObject): JsonObject { /** * Prepare a config document for the OpenCode worker: - * V2 migrate → model materialization → MCP allow/deny filter → OpenCode Config wire shape. + * V2 migrate → model materialization → MCP allow/deny filter → platform automation inject → OpenCode Config wire. + * Platform automation is injected after migrate/materialize so a second migrate pass cannot strip it. */ export function prepareConfigForSync(input: JsonObject | null | undefined): JsonObject { - return toOpencodeWireConfig(materializeMcpAllowDeny(materializeModelAllowDeny(migrateConfigV1ToV2(input ?? {})))); + return toOpencodeWireConfig( + injectPlatformAutomationConfig( + materializeMcpAllowDeny(materializeModelAllowDeny(migrateConfigV1ToV2(input ?? {}))), + ), + ); } /** diff --git a/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.component.scss b/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.component.scss index 40687ef3e..50f81c3d9 100644 --- a/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.component.scss +++ b/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.component.scss @@ -30,8 +30,5 @@ } .fpc-notification-indicator__dot--security-warning { - width: 0.375rem; - height: 0.375rem; - border-radius: 50%; background-color: var(--bs-warning); } diff --git a/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.component.ts b/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.component.ts index fa14fc5bf..7aab8a4bd 100644 --- a/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.component.ts +++ b/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; /** - * `default` / `unread` — primary dot, `security-warning` — small warning dot, - * `git` — info dot for repository activity, `both` — unread and security warning together. + * `default` / `unread` — primary dot, `security-warning` — warning dot (e.g. MFA), + * `git` — info dot for repository activity, `both` — unread and git together. */ export type FpcNotificationIndicatorKind = 'default' | 'security-warning' | 'git' | 'unread' | 'both'; @@ -47,11 +47,11 @@ export class FpcNotificationIndicatorComponent { return kind === 'default' || kind === 'unread' || kind === 'both'; }); - protected readonly showGit = computed(() => this.kind() === 'git'); - - protected readonly showSecurityWarning = computed(() => { + protected readonly showGit = computed(() => { const kind = this.kind(); - return kind === 'security-warning' || kind === 'both'; + return kind === 'git' || kind === 'both'; }); + + protected readonly showSecurityWarning = computed(() => this.kind() === 'security-warning'); } diff --git a/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.stories.ts b/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.stories.ts index adb09b4f3..e94f585df 100644 --- a/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.stories.ts +++ b/libs/domains/shared/frontend/ui-components/src/lib/notification-indicator/notification-indicator.stories.ts @@ -41,7 +41,7 @@ export const SecurityWarning: Story = { args: { kind: 'security-warning', ariaLa export const Git: Story = { args: { kind: 'git', ariaLabel: 'Repository activity' } }; -export const Both: Story = { args: { kind: 'both', ariaLabel: 'Unread and security warning' } }; +export const Both: Story = { args: { kind: 'both', ariaLabel: 'Unread and repository activity' } }; export const AbsolutePlacement: Story = { args: { placement: 'absolute' },