diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 9d53a329..be3b5cce 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,9 +16,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Latest implementation checkpoint: `feat(captain): align preferences payloads`. -- Latest documentation checkpoint: this documentation-only checkpoint, landing the remaining B11.3/B12 execution plan after B11.3a Captain preferences. -- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage. Next active slice is B11.3b Captain playground contract and safe LLM fallback, followed by B11.3c document sync/indexing, B11.3d Copilot tasks/tool-call depth, B11.3e streaming/realtime compatibility, and B12 reused frontend smoke. +- Latest implementation checkpoint: `feat(captain): align playground fallback`. +- Latest documentation checkpoint: this checkpoint, recorded with the B11.3b Captain playground fallback implementation. +- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback. Next active slice is B11.3c document sync/indexing and Meilisearch/embedding gates, followed by B11.3d Copilot tasks/tool-call depth, B11.3e streaming/realtime compatibility, and B12 reused frontend smoke. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -44,11 +44,10 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 1 | B11.3b | Captain assistant playground accepts the Chatwoot frontend payload and returns a safe raw response when no LLM is configured. | Captain playground handler/service fixtures, account-scope tests, no-LLM fallback tests. | -| 2 | B11.3c | Captain document sync has observable Meilisearch/embedding gates and fakeable indexing boundaries. | Document sync/indexing fixtures for disabled, failure, and fake-success paths. | -| 3 | B11.3d | Copilot tasks, suggestions, and tool-call records persist enough frontend state for reloads. | Copilot task/tool-call handler/service fixtures and serializer tests. | -| 4 | B11.3e | Streaming/realtime paths have frontend-compatible non-streaming or disabled fallbacks until true streaming is wired. | Deterministic response tests and event/push payload tests. | -| 5 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. | +| 1 | B11.3c | Captain document sync has observable Meilisearch/embedding gates and fakeable indexing boundaries. | Document sync/indexing fixtures for disabled, failure, and fake-success paths. | +| 2 | B11.3d | Copilot tasks, suggestions, and tool-call records persist enough frontend state for reloads. | Copilot task/tool-call handler/service fixtures and serializer tests. | +| 3 | B11.3e | Streaming/realtime paths have frontend-compatible non-streaming or disabled fallbacks until true streaming is wired. | Deterministic response tests and event/push payload tests. | +| 4 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. | ## Execution Snapshot @@ -58,7 +57,7 @@ Next ordered checkpoints: | Phase 1 | Meilisearch search engine | Review | B6 payload parity, optional live gate, and DB-fallback hardening are implemented; an actual live Meilisearch run is optional and environment-dependent | | Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback | | Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based | -| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, and B10 are in Review; B11 Captain resources, preferences, and Copilot thread/message payloads are landed, while Copilot tasks/tool-call depth and document/LLM/Meilisearch gates remain active | +| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, and B10 are in Review; B11 Captain resources, preferences, playground, and Copilot thread/message payloads are landed, while Copilot tasks/tool-call depth and document/LLM/Meilisearch gates remain active | | Phase 5 | Background jobs and integrations | Planned | durable worker choice and job parity are open | | Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad | | Phase 7 | Verification harness | Planned | search live gate and reused-frontend smoke harness are not complete | @@ -155,14 +154,15 @@ This ledger records the committed parity checkpoints that future slices should b | `feat(copilot): align thread message payloads` | Completes the B11.2 Copilot thread/message frontend contract: thread create accepts Chatwoot `{ message, assistant_id, conversation_id }`, creates the initial user message plus deterministic assistant fallback when no LLM provider is configured, validates assistant account scope, and returns raw thread payloads. Thread list returns `{ payload }` scoped to current account/user with Chatwoot page size/order. Nested `copilot_messages` list/create returns raw message payloads with embedded thread/user/assistant push data and `204` delete remains available for local compatibility. | `go test ./internal/handler/api/v1 -run 'Copilot' -count=1`; `go test ./internal/handler/api/v1 -run 'Captain\|Copilot' -count=1`; `go test ./internal/service -run 'Captain\|Copilot' -count=1`; `go test ./internal/repository -run 'Copilot' -count=1`; handler/service/repository package tests; escalated full `go test ./...`; `git diff --check`. Focused handler fixtures cover no local envelopes, account/user isolation, assistant scope, nested message order, and no-LLM assistant fallback persistence. | Continue B11.3 with Captain document sync/indexing, Meilisearch/embedding gates, Copilot tasks/preferences/tool-call/playground depth, streaming/realtime compatibility, and reused frontend smoke coverage. | | `feat(captain): align preferences payloads` | Starts B11.3a by replacing the legacy local `captain_preferences` CRUD response with Chatwoot's account-level Captain preferences contract: `GET/PUT /captain/preferences` now return raw `{ providers, models, features }`, `captain_models` and `captain_features` persist on accounts, updates merge with existing account settings, invalid model selections return `422`, and update is administrator-gated. Route artifacts were regenerated after removing frontend-unused POST/DELETE preference routes and adding exact no-trailing-slash GET/PUT paths. | `go test ./internal/handler/api/v1 -run 'CaptainPreference' -count=1`; `go test ./internal/service -run 'CaptainPreference' -count=1`; `go test ./internal/handler/api/v1 -run 'Captain\|Copilot' -count=1`; `go test ./internal/service -run 'Captain\|Copilot' -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./internal/service -count=1`; `go test ./internal/model -count=1`; `go test ./internal/router -count=1`; `go test ./cmd/migrate -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; escalated full `go test ./...`; `git diff --check`. | Continue B11.3b with document sync/indexing, Meilisearch/embedding gates, remaining Copilot task/tool-call/playground depth, streaming/realtime compatibility, and reused frontend smoke coverage. | | `docs: land captain copilot completion plan` | Documentation-only handoff checkpoint that breaks the remaining B11.3 work into explicit Captain playground, document sync/indexing, Copilot task/tool-call, streaming/realtime, and B12 smoke slices. It also records the reference files, required payload contracts, fallback behavior, verification commands, and status gates so no Hermes-era plan remains implicit. | `git diff --check`; no Go code changed. | Start B11.3b Captain playground implementation from the recorded contract. | +| `feat(captain): align playground fallback` | Completes B11.3b Captain assistant playground parity: `POST /captain/assistants/:assistant_id/playground` now accepts the Chatwoot frontend top-level `message_content` and `message_history` payload, enforces account-scoped assistant lookup, returns raw legacy `{ content }` or v2 `{ response }` payloads, appends the current user message once for `captain_integration_v2`, preserves history ordering through the LLM boundary, and returns a deterministic raw disabled response when no LLM provider is configured. | `go test ./internal/handler/api/v1 -run CaptainAssistant -count=1`; `go test ./internal/service -run Captain -count=1`; `go test ./internal/handler/api/v1 -run 'Captain\|Copilot' -count=1`; `go test ./internal/service -run 'Captain\|Copilot' -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./internal/service -count=1`; `go test ./...`; `git diff --check`. Focused handler fixtures cover raw no-envelope fallback, no-history default, cross-account rejection, v2 append/no-duplicate history behavior, and fake LLM request capture. | Continue B11.3c with Captain document sync/indexing and Meilisearch/embedding gates. | ## Next Slice Contract -Completed implementation slice: B11.3a now aligns Captain preferences show/update payloads with the reused Chatwoot frontend, building on B11.1a-B11.2 Captain/Copilot resource contracts. +Completed implementation slice: B11.3b now aligns Captain assistant playground payloads and fallback behavior with the reused Chatwoot frontend, building on B11.1a-B11.3a Captain/Copilot resource contracts. Completed documentation slice: the remaining development plan is now fully landed in this tracker. B11.3 is split into concrete Captain playground, document sync/indexing, Copilot task/tool-call, and streaming/realtime checkpoints. B12 has a repeatable smoke-report contract. B9.3 delayed/durable worker scheduling and B9.4 macro attachment depth remain named Phase 5 follow-ups. -Next implementation slice: start B11.3b Captain assistant playground parity. Do not expand to document indexing or Copilot tasks until playground request/response shape, account scoping, and no-LLM fallback are covered by tests. +Next implementation slice: start B11.3c Captain document sync/indexing and Meilisearch/embedding gates. Do not expand to Copilot tasks until disabled config, failed sync observability, fake successful indexing, and account-scoped document lookup are covered by tests. | Step | Required result | Reference source | Verification | | --- | --- | --- | --- | @@ -187,7 +187,7 @@ Next implementation slice: start B11.3b Captain assistant playground parity. Do | N19 | Keep B11.1c Captain document/response/bulk/test resources as current Captain baseline. | Captain documents, assistant responses, bulk actions, custom-tool test controllers, Jbuilder views, and dashboard clients. | Done by `feat(captain): align document response actions`; documents, assistant responses, bulk resource actions, and unsaved custom-tool test use account-scoped Chatwoot payloads with a fakeable HTTP boundary. | | N20 | Keep B11.2 Copilot thread/message resources as current Copilot baseline. | `copilot_threads_controller.rb`, `copilot_messages_controller.rb`, Copilot Jbuilder partials, and dashboard `copilotThreads.js`/`copilotMessages.js` clients. | Done by `feat(copilot): align thread message payloads`; threads/messages persist account/user-scoped Chatwoot payloads and no-LLM fallback assistant messages. | | N21 | Keep B11.3a Captain preferences as current account-level Captain config baseline. | `preferences_controller.rb`, `CaptainFeaturable`, `config/llm.yml`, dashboard `preferences.js` API/store. | Done by `feat(captain): align preferences payloads`; raw model/provider/feature payloads, merge update behavior, account storage, admin gate, and exact preference routes are covered. | -| N22 | Implement B11.3b Captain assistant playground payload parity. | `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb#playground`, dashboard `api/captain/assistant.js`, assistant playground component/store. | Top-level `message_content` and `message_history` are accepted; account-scoped assistant lookup is enforced; legacy `{ content }` and v2 `{ response }` response shape differences are handled or explicitly feature-gated; no-LLM fallback returns raw frontend-compatible JSON without 500. | +| N22 | Keep B11.3b Captain assistant playground payload parity as current playground baseline. | `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb#playground`, dashboard `api/captain/assistant.js`, assistant playground component/store. | Done by `feat(captain): align playground fallback`; top-level `message_content` and `message_history` are accepted, account-scoped assistant lookup is enforced, legacy `{ content }` and v2 `{ response }` shapes are covered, and no-LLM fallback returns raw frontend-compatible JSON without 500. | | N23 | Implement B11.3c Captain document sync/indexing gates. | Captain document sync service/jobs, document controller `sync`, existing Meilisearch engine, and any local embedding boundary. | Disabled config, fake successful indexing, failed sync metadata, and account-scoped document lookup are covered without opening external network connections in default tests. | | N24 | Implement B11.3d Copilot task/tool-call persistence. | `resource :tasks` routes, Copilot/Captain task services, dashboard Copilot clients, current local `copilot_*` models. | Rewrite/summarize/reply suggestion/label suggestion/follow-up requests persist task/tool-call state or return documented raw disabled states; serializers survive frontend reload. | | N25 | Implement B11.3e streaming/realtime compatibility. | Chatwoot Copilot/Captain streaming, push/event payloads, current Go channel dispatcher. | If streaming is not implemented, the endpoint returns deterministic non-streaming payloads and records the fallback; no route returns placeholder success or local envelope. | @@ -786,7 +786,7 @@ B11 Captain/Copilot breakdown: | B11.1c | Align Captain documents, assistant responses, bulk actions, and custom-tool test execution. | `documents_controller.rb`, `assistant_responses_controller.rb`, `bulk_actions_controller.rb`, `custom_tools_controller#test`, dashboard Captain clients. | Handler/service fixtures for document list/create/show/sync/delete, response list/show/create/update/delete, bulk-action payloads, test execution errors, account scoping, sync status, and no local response envelopes. | Done by `feat(captain): align document response actions` | | B11.2 | Align Copilot thread/message APIs, account/user scoping, assistant scope, frontend payloads, and no-LLM fallback persistence. | `copilot_threads_controller.rb`, `copilot_messages_controller.rb`, Copilot Jbuilder partials, dashboard `copilotThreads.js` and `copilotMessages.js`. | Handler tests cover thread/message create/list/get/delete, nested message ordering, assistant/account isolation, user isolation, and deterministic disabled LLM fallback. | Done by `feat(copilot): align thread message payloads` | | B11.3a | Align Captain preferences show/update payloads, account-level model/feature storage, and admin update gate. | `preferences_controller.rb`, `CaptainFeaturable`, `config/llm.yml`, dashboard `preferences.js` API/store. | Handler tests cover raw `{ providers, models, features }`, default selected models, merge update behavior, account persistence, invalid model rejection, admin gate, and exact preference routes. | Done by `feat(captain): align preferences payloads` | -| B11.3b | Align Captain assistant playground request/response behavior and no-LLM fallback. | `assistants_controller.rb#playground`, `Captain::Llm::AssistantChatService`, `AgentRunnerService`, dashboard `api/captain/assistant.js`, playground component/store. | Handler/service tests cover top-level `message_content`, `message_history`, account-scoped assistant lookup, no-history payloads, no duplicate latest user message for v2 behavior if enabled, raw `{ content }` or `{ response }` response shape, and safe no-LLM fallback. | Todo | +| B11.3b | Align Captain assistant playground request/response behavior and no-LLM fallback. | `assistants_controller.rb#playground`, `Captain::Llm::AssistantChatService`, `AgentRunnerService`, dashboard `api/captain/assistant.js`, playground component/store. | Handler/service tests cover top-level `message_content`, `message_history`, account-scoped assistant lookup, no-history payloads, no duplicate latest user message for v2 behavior if enabled, raw `{ content }` or `{ response }` response shape, and safe no-LLM fallback. | Done by `feat(captain): align playground fallback` | | B11.3c | Implement document sync/indexing gates for Meilisearch or the chosen embedding/search backend. | Captain document sync/indexing services, document controller `sync`, existing Meilisearch engine, local embedding/config boundaries. | Tests cover disabled config, failed sync observability, successful fake backend indexing, account-scoped document lookup, and no external network call in default test mode. | Todo | | B11.3d | Finish Copilot task, suggestion, and tool-call persistence depth. | `resource :tasks` routes in `reference/chatwoot/config/routes.rb:62-89`, Copilot/Captain task services, dashboard Copilot clients. | Tests cover rewrite/summarize/reply suggestion/label suggestion/follow-up request payloads, persisted task/tool-call records, reloadable serializers, account/user scoping, and raw disabled state when provider config is missing. | Todo | | B11.3e | Add streaming/realtime compatibility or deterministic non-streaming fallback. | Chatwoot Captain/Copilot streaming paths, push payloads, current Go dispatcher/realtime boundaries. | Tests prove endpoints never return placeholder success/local envelopes; non-streaming fallback payloads are deterministic and frontend-compatible until true streaming is implemented. | Todo | @@ -847,6 +847,16 @@ B11.3b Captain playground landing contract: | No LLM config | The reused frontend must not fail just because provider credentials are missing in local/dev mode. | Guard nil/missing LLM providers and return deterministic raw frontend-compatible JSON with an explanatory disabled/fallback message. | No-provider test returns `200` and a raw payload, not panic/500. | | Message history | Chatwoot forwards `message_history` entries with `role`, `content`, and optional `agent_name`. | Preserve history order through the service boundary; include the current message only according to the selected legacy/v2 behavior. | Fake LLM/service test captures history and current message handling. | +B11.3b current checkpoint: + +- Reference files inspected for this slice: `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb#playground`, `reference/chatwoot/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb`, dashboard `api/captain/assistant.js`, and `components-next/captain/assistant/AssistantPlayground.vue`. +- Playground now accepts the exact frontend body with top-level `message_content` and `message_history`, while retaining nested `{ assistant: ... }` compatibility inside the handler binding. +- Assistant lookup is account-scoped through `GetByAccountAndID`, so another account cannot invoke playground for an assistant primary key. +- Legacy mode returns raw `{ content: ... }`, matching `Captain::Llm::AssistantChatService`; `captain_integration_v2` returns raw `{ response: ... }`, matching `AgentRunnerService`. +- V2 history handling appends the current user message only when it is not already the last `{ role: "user", content: message_content }` entry. +- Nil/missing LLM provider now returns deterministic raw fallback JSON instead of panic/500, keeping local/dev frontend use unblocked. +- Focused tests cover raw no-envelope fallback, no-history default, cross-account rejection, v2 append/no-duplicate behavior, and fake LLM request history capture. + B11.3c-B11.3e follow-up contracts: | Slice | Landing rule | Status gate | @@ -1273,7 +1283,7 @@ Enterprise tracking table: | P4.3 | Custom roles/permissions | `internal/model/custom_role.go`, `internal/service/custom_role_service.go`, `internal/middleware/role_check.go`, `internal/handler/api/v1/custom_role_handler.go` | Permission arrays, raw admin payloads, administrator gates, account-user resolution, profile permissions, and delete nullification are implemented; keep in Review until frontend smoke covers enterprise settings. | Review | | P4.4 | Agent capacity | `internal/model/agent_capacity_policy.go`, `internal/service/agent_capacity_policy_service.go`, `internal/handler/api/v1/agent_capacity_handler.go`, `internal/autoassignment/*` | First align AgentCapacityPolicy API, serializers, nested users, and `InboxCapacityLimit` data contracts; then enforce capacity in manual/auto assignment. | Review | | P4.5 | Inbox limits | `internal/model/agent_capacity_policy.go` `InboxCapacityLimit`, plus legacy `internal/model/inbox_limit.go`, `internal/service/inbox_limit_service.go`, `internal/repository/inbox_limit_repo.go`, `internal/handler/api/v1/inbox_limit_handler.go` | Chatwoot enterprise nested `agent_capacity_policies/:policy_id/inbox_limits`, assignment-path enforcement, and account-level `usage_limits[:inboxes]` create-path enforcement are covered. | Review | -| P4.6 | Captain/Copilot | `internal/model/captain_models.go`, `internal/model/copilot_models.go`, `internal/service/captain_*`, `internal/service/copilot_*`, `internal/handler/api/v1/captain_*`, `internal/handler/api/v1/copilot_handler.go` | Captain resource payloads through B11.1c, Copilot thread/message payloads through B11.2, and Captain preferences through B11.3a are aligned; complete B11.3b playground, B11.3c document/LLM/Meilisearch gates, B11.3d Copilot task/tool-call depth, and B11.3e streaming compatibility. | Doing | +| P4.6 | Captain/Copilot | `internal/model/captain_models.go`, `internal/model/copilot_models.go`, `internal/service/captain_*`, `internal/service/copilot_*`, `internal/handler/api/v1/captain_*`, `internal/handler/api/v1/copilot_handler.go` | Captain resource payloads through B11.1c, Copilot thread/message payloads through B11.2, Captain preferences through B11.3a, and Captain playground through B11.3b are aligned; complete B11.3c document/LLM/Meilisearch gates, B11.3d Copilot task/tool-call depth, and B11.3e streaming compatibility. | Doing | | P4.7 | CSAT | `internal/csat/*`, `internal/automation/csat_survey_*`, `internal/handler/api/v1/csat_*`, `internal/service/csat_metrics_service.go` | Account-side list/metrics/review-note payloads, public update depth, resolve-triggered generic survey send, and download CSV are aligned; channel-specific template send hooks remain Phase 5 integration work. | Review | | P4.8 | Automation and macros | `internal/automation/*`, `internal/handler/api/v1/automation_rule_handler.go`, `internal/handler/api/v1/macro_handler.go` | Automation-rule CRUD/listener/log/external-action delivery and macro CRUD/display-ID execution parity are implemented; durable delayed actions and deeper macro attachment/file parity remain B9.3/B9.4 follow-ups. | Review | | P4.9 | Assignment policies | `internal/autoassignment/*`, `internal/automation/agent_bot_rule_listener.go` | Match Chatwoot assignment policy behavior and availability/capacity rules. | Doing | @@ -1290,7 +1300,7 @@ Enterprise work package breakdown: | Audit | Audit model parity, mutating action coverage, request metadata, filters/pagination, admin endpoint payloads. | List/admin payload parity is covered by `feat(audit): align chatwoot audit log payloads`; named representative writer coverage is covered by `feat(audit): record enterprise mutations` and `feat(audit): cover operational mutations`. | Review | | Custom roles | Permission-key parity, account-user role resolution, policy middleware, create/update/delete behavior. | Permission arrays, raw admin payloads, admin gates, RBAC/account-scope/profile resolution, and delete nullification are covered by `feat(custom-roles): align chatwoot permissions`. | Review | | Inbox limits | Account/inbox limit models, create/update enforcement, UI-readable limit responses, admin overrides. | Chatwoot nested capacity-limit API, assignment-path enforcement, and account-level create-limit `402` behavior are covered by B5/B7/B10.4. | Review | -| Captain/Copilot | Assistants, inbox bindings, scenarios, responses, documents, tools, preferences, copilot threads/messages, tasks, streaming/tool-call behavior. | Assistant CRUD/tools/inbox binding fixtures are covered by `feat(captain): align assistant resources`; scenario/custom-tool fixtures are covered by `feat(captain): align scenario and tool payloads`; document/assistant-response/bulk/custom-tool-test fixtures are covered by `feat(captain): align document response actions`; Copilot thread/message fixtures are covered by `feat(copilot): align thread message payloads`; preferences fixtures are covered by `feat(captain): align preferences payloads`; B11.3b-B11.3e now track playground, external LLM/document gates, remaining Copilot task/tool-call behavior, streaming fallback, and frontend smoke screens. | Doing | +| Captain/Copilot | Assistants, inbox bindings, scenarios, responses, documents, tools, preferences, copilot threads/messages, tasks, streaming/tool-call behavior. | Assistant CRUD/tools/inbox binding fixtures are covered by `feat(captain): align assistant resources`; scenario/custom-tool fixtures are covered by `feat(captain): align scenario and tool payloads`; document/assistant-response/bulk/custom-tool-test fixtures are covered by `feat(captain): align document response actions`; Copilot thread/message fixtures are covered by `feat(copilot): align thread message payloads`; preferences fixtures are covered by `feat(captain): align preferences payloads`; playground fixtures are covered by `feat(captain): align playground fallback`; B11.3c-B11.3e now track external LLM/document gates, remaining Copilot task/tool-call behavior, streaming fallback, and frontend smoke screens. | Doing | Enterprise acceptance gates: @@ -1529,4 +1539,5 @@ Verification milestone gates: - 2026-06-05: B11.1b Captain scenario/custom-tool checkpoint prepared as `feat(captain): align scenario and tool payloads`; scenarios now accept nested frontend bodies, enforce account/assistant scope, list only enabled scenarios, and return raw scenario/list/204 payloads. Custom tools now accept nested frontend bodies, auto-generate slugs, enforce account scope, and return raw tool/list/204 payloads. Focused CaptainScenario/CaptainCustomTool/CaptainResource tests plus Captain/Copilot focused handler/service tests passed. Next slice is B11.1c documents, assistant responses, bulk actions, and custom-tool test execution. - 2026-06-05: B11.1c Captain document/response/action checkpoint prepared as `feat(captain): align document response actions`; documents now return Chatwoot raw/list payloads with account scope and `202` sync marking, assistant responses now support nested bodies, filters, raw serializers, edited tracking, and `204` delete, bulk actions now accept Chatwoot `AssistantResponse`/`AssistantDocument` resource payloads, and custom-tool test now executes unsaved configs through a fakeable HTTP boundary returning `{ status, body }`. Focused B11.1c handler tests, Captain/Copilot service tests, Captain repository tests, handler/service package tests, escalated full `go test ./...`, and `git diff --check` passed. Next slice is B11.2 Copilot persistence and safe LLM/document gates. - 2026-06-05: B11.2 Copilot thread/message checkpoint prepared as `feat(copilot): align thread message payloads`; Copilot threads now accept Chatwoot `{ message, assistant_id, conversation_id }`, validate account-scoped assistants, create initial user plus safe no-LLM assistant messages, and return raw thread serializers. Nested Copilot messages now list/create raw message payloads with embedded thread push data, current account/user scoping, ascending message order, and no local envelopes. Focused Copilot handler/service/repository tests, Captain/Copilot handler/service tests, handler/service/repository package tests, escalated full `go test ./...`, and `git diff --check` passed. Next slice is B11.3 document sync/indexing, Meilisearch/embedding gates, Copilot tasks/preferences/tool-call depth, and streaming/realtime compatibility. -- 2026-06-05: B11.3a Captain preferences checkpoint prepared as `feat(captain): align preferences payloads`; Captain preferences now use Chatwoot's raw `{ providers, models, features }` show/update payload, persist `captain_models`/`captain_features` on accounts, merge partial preference updates, reject invalid model selections with `422`, gate updates to administrators, and expose exact no-trailing-slash GET/PUT preference routes. Focused CaptainPreference handler/service tests, Captain/Copilot handler/service tests, handler/service/model/router package tests, migration tests, route dump/parity regeneration, escalated full `go test ./...`, and `git diff --check` passed. Next slice is B11.3b document sync/indexing, Meilisearch/embedding gates, Copilot tasks/tool-call depth, and streaming/realtime compatibility. +- 2026-06-05: B11.3a Captain preferences checkpoint prepared as `feat(captain): align preferences payloads`; Captain preferences now use Chatwoot's raw `{ providers, models, features }` show/update payload, persist `captain_models`/`captain_features` on accounts, merge partial preference updates, reject invalid model selections with `422`, gate updates to administrators, and expose exact no-trailing-slash GET/PUT preference routes. Focused CaptainPreference handler/service tests, Captain/Copilot handler/service tests, handler/service/model/router package tests, migration tests, route dump/parity regeneration, escalated full `go test ./...`, and `git diff --check` passed. Next slice is B11.3b Captain playground parity. +- 2026-06-05: B11.3b Captain playground checkpoint prepared as `feat(captain): align playground fallback`; playground now accepts Chatwoot top-level `message_content/message_history`, scopes assistant lookup to the account, returns raw legacy `{ content }` or v2 `{ response }`, appends current v2 user messages without duplication, preserves history into the fakeable LLM boundary, and returns deterministic no-LLM fallback JSON instead of 500. Focused CaptainAssistant/Captain tests, handler/service package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B11.3c document sync/indexing and Meilisearch/embedding gates. diff --git a/internal/handler/api/v1/captain_assistant_handler.go b/internal/handler/api/v1/captain_assistant_handler.go index 9910ad93..115bc8ff 100644 --- a/internal/handler/api/v1/captain_assistant_handler.go +++ b/internal/handler/api/v1/captain_assistant_handler.go @@ -301,8 +301,13 @@ func (h *CaptainAssistantHandler) Tools(c *gin.Context) { } // GenerateResponse generates an AI response via RAG. -// POST /api/v1/accounts/:account_id/captain_assistants/:id/generate_response +// POST /api/v1/accounts/:account_id/captain/assistants/:assistant_id/playground func (h *CaptainAssistantHandler) GenerateResponse(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") + return + } id, err := parseUintAnyParam(c, "assistant_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") @@ -311,39 +316,52 @@ func (h *CaptainAssistantHandler) GenerateResponse(c *gin.Context) { var req struct { Assistant struct { - MessageContent string `json:"message_content"` - Query string `json:"query"` + MessageContent string `json:"message_content"` + Query string `json:"query"` + MessageHistory []service.PlaygroundMessage `json:"message_history"` } `json:"assistant"` - MessageContent string `json:"message_content"` - Query string `json:"query"` + MessageContent string `json:"message_content"` + Query string `json:"query"` + MessageHistory []service.PlaygroundMessage `json:"message_history"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - query := req.Query - if query == "" { - query = req.MessageContent + messageContent := req.MessageContent + if messageContent == "" { + messageContent = req.Query } - if query == "" { - query = req.Assistant.Query + if messageContent == "" { + messageContent = req.Assistant.MessageContent } - if query == "" { - query = req.Assistant.MessageContent + if messageContent == "" { + messageContent = req.Assistant.Query } - if query == "" { + if messageContent == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "message_content is required") return } + messageHistory := req.MessageHistory + if len(messageHistory) == 0 && len(req.Assistant.MessageHistory) > 0 { + messageHistory = req.Assistant.MessageHistory + } - result, err := h.svc.GenerateResponse(c.Request.Context(), id, query) + result, err := h.svc.GeneratePlaygroundResponse(c.Request.Context(), accountID, id, service.PlaygroundRequest{ + MessageContent: messageContent, + MessageHistory: messageHistory, + }) if err != nil { applogger.L().Errorf("GenerateResponse: %v", err) + if captainAssistantErrorStatus(err) == http.StatusNotFound { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "assistant not found") + return + } response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate response") return } - c.JSON(http.StatusOK, gin.H{"response": result}) + c.JSON(http.StatusOK, result) } func bindCaptainAssistantPayload(c *gin.Context, dst any) error { diff --git a/internal/handler/api/v1/captain_assistant_handler_test.go b/internal/handler/api/v1/captain_assistant_handler_test.go index 6acb0459..93bedecf 100644 --- a/internal/handler/api/v1/captain_assistant_handler_test.go +++ b/internal/handler/api/v1/captain_assistant_handler_test.go @@ -2,6 +2,7 @@ package v1 import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -10,6 +11,7 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" @@ -51,12 +53,43 @@ func setupCaptainAssistantHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) { assistants.GET("/:assistant_id", handler.Get) assistants.PUT("/:assistant_id", handler.Update) assistants.DELETE("/:assistant_id", handler.Delete) + assistants.POST("/:assistant_id/playground", handler.GenerateResponse) assistants.GET("/:assistant_id/inboxes", handler.ListInboxes) assistants.POST("/:assistant_id/inboxes", handler.AssociateInbox) assistants.DELETE("/:assistant_id/inboxes/:inbox_id", handler.DissociateInbox) return router, db } +func setupCaptainAssistantHandlerTestWithProvider(t *testing.T, provider llm.Provider) (*gin.Engine, *gorm.DB) { + t.Helper() + gin.SetMode(gin.TestMode) + dbName := fmt.Sprintf("file:%s-provider?mode=memory&cache=private", t.Name()) + db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Account{}, + &model.Inbox{}, + &model.CaptainAssistant{}, + &model.CaptainInbox{}, + )) + t.Cleanup(func() { + sqlDB, _ := db.DB() + sqlDB.Close() + }) + + assistantRepo := repository.NewCaptainAssistantRepo(db) + inboxRepo := repository.NewCaptainInboxRepo(db) + documentRepo := repository.NewCaptainDocumentRepo(db) + responseRepo := repository.NewCaptainAssistantResponseRepo(db) + svc := service.NewCaptainAssistantService(assistantRepo, inboxRepo, documentRepo, responseRepo, provider) + handler := NewCaptainAssistantHandler(svc) + + router := gin.New() + assistants := router.Group("/api/v1/accounts/:account_id/captain/assistants") + assistants.POST("/:assistant_id/playground", handler.GenerateResponse) + return router, db +} + func seedCaptainAssistantAccount(t *testing.T, db *gorm.DB, name string) *model.Account { t.Helper() account := &model.Account{Name: name, Locale: "en", Active: true} @@ -182,3 +215,97 @@ func TestCaptainAssistantHandler_AccountScopedShowAndInboxBinding(t *testing.T) w = captainAssistantJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d/inboxes/%d", basePath, assistant.ID, inbox.ID), nil) assert.Equal(t, http.StatusNoContent, w.Code) } + +func TestCaptainAssistantHandler_PlaygroundLegacyNoLLMFallback(t *testing.T) { + router, db := setupCaptainAssistantHandlerTest(t) + account := seedCaptainAssistantAccount(t, db, "Captain Org") + assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Config: json.RawMessage(`{"model":"gpt-test"}`), Status: model.AssistantStatusActive} + require.NoError(t, db.Create(assistant).Error) + + body := map[string]any{ + "message_content": "Hello assistant", + "message_history": []map[string]any{ + {"role": "user", "content": "Previous message"}, + {"role": "assistant", "content": "Previous response", "agent_name": "billing_scenario"}, + }, + } + w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), body) + assert.Equal(t, http.StatusOK, w.Code) + + var payload map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) + assert.NotContains(t, payload, "success") + assert.NotContains(t, payload, "data") + assert.Equal(t, "Captain assistant response generation is not configured for this account.", payload["content"]) + assert.NotContains(t, payload, "response") +} + +func TestCaptainAssistantHandler_PlaygroundDefaultsHistoryAndScopesAccount(t *testing.T) { + router, db := setupCaptainAssistantHandlerTest(t) + account := seedCaptainAssistantAccount(t, db, "Account One") + otherAccount := seedCaptainAssistantAccount(t, db, "Account Two") + assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive} + require.NoError(t, db.Create(assistant).Error) + + body := map[string]any{"message_content": "Hello assistant"} + w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), body) + assert.Equal(t, http.StatusOK, w.Code) + + w = captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", otherAccount.ID, assistant.ID), body) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCaptainAssistantHandler_PlaygroundV2AppendsCurrentMessageOnce(t *testing.T) { + provider := &captainPlaygroundFakeProvider{content: "Assistant response"} + router, db := setupCaptainAssistantHandlerTestWithProvider(t, provider) + account := seedCaptainAssistantAccount(t, db, "Captain Org") + account.FeatureFlags = `{"captain_integration_v2":true}` + require.NoError(t, db.Save(account).Error) + assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Config: json.RawMessage(`{"model":"gpt-test","temperature":0.2}`), Status: model.AssistantStatusActive} + require.NoError(t, db.Create(assistant).Error) + + body := map[string]any{ + "message_content": "Hello assistant", + "message_history": []map[string]any{ + {"role": "user", "content": "Previous message"}, + {"role": "assistant", "content": "Previous response", "agent_name": "billing_scenario"}, + }, + } + w := captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), body) + assert.Equal(t, http.StatusOK, w.Code) + var payload map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(t, "Assistant response", payload["response"]) + assert.NotContains(t, payload, "content") + require.Len(t, provider.lastRequest.Messages, 4) + assert.Equal(t, "Previous message", provider.lastRequest.Messages[1].Content) + assert.Equal(t, "Previous response", provider.lastRequest.Messages[2].Content) + assert.Equal(t, "Hello assistant", provider.lastRequest.Messages[3].Content) + + body = map[string]any{ + "message_content": "Hello assistant", + "message_history": []map[string]any{{"role": "user", "content": "Hello assistant"}}, + } + w = captainAssistantJSONRequest(t, router, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/captain/assistants/%d/playground", account.ID, assistant.ID), body) + assert.Equal(t, http.StatusOK, w.Code) + require.Len(t, provider.lastRequest.Messages, 2) + assert.Equal(t, "Hello assistant", provider.lastRequest.Messages[1].Content) +} + +type captainPlaygroundFakeProvider struct { + content string + lastRequest llm.ChatRequest +} + +func (p *captainPlaygroundFakeProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { + p.lastRequest = req + return &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Role: "assistant", Content: p.content}}}}, nil +} + +func (p *captainPlaygroundFakeProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) { + return &llm.EmbeddingResponse{}, nil +} + +func (p *captainPlaygroundFakeProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error { + return nil +} diff --git a/internal/repository/captain_assistant_repo.go b/internal/repository/captain_assistant_repo.go index 304d092a..f911e012 100644 --- a/internal/repository/captain_assistant_repo.go +++ b/internal/repository/captain_assistant_repo.go @@ -36,6 +36,14 @@ func (r *CaptainAssistantRepo) GetByAccountAndID(ctx context.Context, accountID, return &assistant, nil } +func (r *CaptainAssistantRepo) GetAccountFeatureFlags(ctx context.Context, accountID uint) (string, error) { + var account model.Account + if err := r.db.WithContext(ctx).Select("feature_flags").First(&account, accountID).Error; err != nil { + return "", err + } + return account.FeatureFlags, nil +} + func (r *CaptainAssistantRepo) Update(ctx context.Context, assistant *model.CaptainAssistant) error { return r.db.WithContext(ctx).Save(assistant).Error } diff --git a/internal/service/captain_assistant_service.go b/internal/service/captain_assistant_service.go index 58dd83df..bf874380 100644 --- a/internal/service/captain_assistant_service.go +++ b/internal/service/captain_assistant_service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" @@ -59,6 +60,17 @@ type UpdateAssistantRequest struct { Status string `json:"status"` } +type PlaygroundMessage struct { + Role string `json:"role"` + Content string `json:"content"` + AgentName string `json:"agent_name,omitempty"` +} + +type PlaygroundRequest struct { + MessageContent string `json:"message_content"` + MessageHistory []PlaygroundMessage `json:"message_history"` +} + // --- CRUD Operations --- // Create creates a new CaptainAssistant. @@ -309,6 +321,118 @@ func (s *CaptainAssistantService) GenerateResponse(ctx context.Context, assistan return resp.Choices[0].Message.Content, nil } +const captainPlaygroundFallbackMessage = "Captain assistant response generation is not configured for this account." + +// GeneratePlaygroundResponse follows Chatwoot Captain assistant playground behavior. +func (s *CaptainAssistantService) GeneratePlaygroundResponse(ctx context.Context, accountID, assistantID uint, req PlaygroundRequest) (map[string]any, error) { + assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID) + if err != nil { + return nil, fmt.Errorf("assistant not found: %w", err) + } + + if s.captainV2Enabled(ctx, accountID) { + history := playgroundMessageHistory(req.MessageHistory, req.MessageContent) + content, err := s.generatePlaygroundLLMResponse(ctx, assistant, history) + if err != nil { + return nil, err + } + return map[string]any{"response": content}, nil + } + + history := append([]PlaygroundMessage{}, req.MessageHistory...) + content, err := s.generatePlaygroundLLMResponse(ctx, assistant, appendAdditionalPlaygroundMessage(history, req.MessageContent)) + if err != nil { + return nil, err + } + return map[string]any{"content": content}, nil +} + +func (s *CaptainAssistantService) generatePlaygroundLLMResponse(ctx context.Context, assistant *model.CaptainAssistant, history []PlaygroundMessage) (string, error) { + if s.llmProvider == nil { + return captainPlaygroundFallbackMessage, nil + } + + cfg, _ := assistant.GetConfig() + messages := []llm.ChatMessage{{Role: "system", Content: buildSystemPrompt(assistant, cfg)}} + for _, message := range history { + if message.Role == "" || message.Content == "" { + continue + } + messages = append(messages, llm.ChatMessage{Role: message.Role, Content: message.Content}) + } + + resp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ + Model: cfg.Model, + Messages: messages, + Temperature: cfg.Temperature, + MaxTokens: 1024, + }) + if err != nil { + applogger.L().Errorf("GeneratePlaygroundResponse LLM call: %v", err) + return "", fmt.Errorf("llm generation failed: %w", err) + } + if len(resp.Choices) == 0 { + return "", fmt.Errorf("no response from LLM") + } + return resp.Choices[0].Message.Content, nil +} + +func (s *CaptainAssistantService) captainV2Enabled(ctx context.Context, accountID uint) bool { + flags, err := s.assistantRepo.GetAccountFeatureFlags(ctx, accountID) + if err != nil { + return false + } + return featureFlagStringEnabled(flags, "captain_integration_v2") +} + +func featureFlagStringEnabled(raw, flag string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return false + } + var objectFlags map[string]bool + if err := json.Unmarshal([]byte(raw), &objectFlags); err == nil { + return objectFlags[flag] + } + var arrayFlags []string + if err := json.Unmarshal([]byte(raw), &arrayFlags); err == nil { + for _, item := range arrayFlags { + if item == flag { + return true + } + } + return false + } + for _, item := range strings.Split(raw, ",") { + if strings.TrimSpace(item) == flag { + return true + } + } + return false +} + +func playgroundMessageHistory(history []PlaygroundMessage, current string) []PlaygroundMessage { + result := append([]PlaygroundMessage{}, history...) + if strings.TrimSpace(current) == "" { + return result + } + currentMessage := PlaygroundMessage{Role: "user", Content: current} + if len(result) > 0 { + last := result[len(result)-1] + if last.Role == currentMessage.Role && last.Content == currentMessage.Content && last.AgentName == "" { + return result + } + } + return append(result, currentMessage) +} + +func appendAdditionalPlaygroundMessage(history []PlaygroundMessage, current string) []PlaygroundMessage { + if strings.TrimSpace(current) == "" { + return history + } + return append(history, PlaygroundMessage{Role: "user", Content: current}) +} + // buildSystemPrompt constructs the system prompt from assistant config and guidelines. func buildSystemPrompt(assistant *model.CaptainAssistant, cfg *model.AssistantConfig) string { prompt := fmt.Sprintf("You are %s, an AI assistant.", assistant.Name)