feat(captain): align playground fallback

This commit is contained in:
2026-06-05 13:09:23 +08:00
parent e1b376cd76
commit 67d182b132
5 changed files with 319 additions and 31 deletions
+27 -16
View File
@@ -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.
@@ -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")
@@ -313,37 +318,50 @@ func (h *CaptainAssistantHandler) GenerateResponse(c *gin.Context) {
Assistant struct {
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"`
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 {
@@ -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
}
@@ -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
}
@@ -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)