Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dec0da3
feat: add Creative.profile_for and profile/skill reserved kinds
sh1nj1 Jul 21, 2026
742ece3
feat: auto-create profile creative for every user including agents
sh1nj1 Jul 21, 2026
afb6a7a
feat: backfill profile creatives for existing users
sh1nj1 Jul 21, 2026
f63559c
feat: agent system prompt dual-reads from profile creative description
sh1nj1 Jul 21, 2026
ac8bff8
feat: mirror agent system prompt into profile description (create/upd…
sh1nj1 Jul 21, 2026
9051f15
fix: store agent prompt in profile markdown_source (lossless), not sa…
sh1nj1 Jul 21, 2026
11a20c4
fix: clearing an agent's system prompt drops the stale profile markdo…
sh1nj1 Jul 21, 2026
49f7f24
test: add empty topics.yml fixture to clean leaked profile-creative t…
sh1nj1 Jul 21, 2026
cb70bc1
fix: address Codex review — reserve kind discriminator, route readers…
sh1nj1 Jul 21, 2026
eeeed34
fix: pre-fill AI edit form from canonical prompt; make prompt read si…
sh1nj1 Jul 21, 2026
b5d59bc
fix: address Codex review — gate prompt sync on submission, converge …
sh1nj1 Jul 21, 2026
e57ba58
fix: route collaborator descriptions through effective_system_prompt
sh1nj1 Jul 21, 2026
ea5f94b
fix: store agent prompt verbatim, skip data-URI image rewrite on sync
sh1nj1 Jul 21, 2026
85619a0
fix: route completion API system prompt through effective_system_prompt
sh1nj1 Jul 21, 2026
0b20884
fix: keep data-URI prompts verbatim on direct profile-creative edits
sh1nj1 Jul 21, 2026
f1ecee5
fix: invalidate session context on direct profile prompt edit
sh1nj1 Jul 21, 2026
1d6e5a8
fix: enforce single profile creative per user with partial unique index
sh1nj1 Jul 21, 2026
591e5b5
fix: collapse duplicate profiles through the model so dependent topic…
sh1nj1 Jul 21, 2026
b2359a5
fix: skip MCP tool extraction for profile creatives
sh1nj1 Jul 21, 2026
6a1bb73
fix: index profile uniqueness on a promoted kind column, not a JSON e…
sh1nj1 Jul 21, 2026
95c573b
fix: add kind column before profile backfills so upgrades create prof…
sh1nj1 Jul 21, 2026
947f073
fix: skip promoted column sync when the column does not exist yet
sh1nj1 Jul 21, 2026
4a5f101
fix: preserve submitted AI prompt on validation error re-render
sh1nj1 Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def create_ai
@user.agent_conf = params[:agent_conf] if @user.respond_to?(:agent_conf=) && params[:agent_conf].present?

if @user.save
@user.sync_profile_system_prompt!
Collavre::Contact.ensure(user: Current.user, contact_user: @user)
share_ai_agent_to_creative(@user, params[:creative_id])
redirect_to user_path(Current.user, tab: "contacts"), notice: I18n.t("collavre.users.create_ai.success")
Expand All @@ -61,6 +62,7 @@ def update_ai
ai_params = params.require(:user).permit(:name, :system_prompt, :llm_vendor, :llm_model, :llm_api_key, :gateway_url, :searchable, :routing_expression, :agent_conf, tools: [])

if @user.update(ai_params)
@user.sync_profile_system_prompt!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve profile prompts when saving agent settings

When an agent's profile Creative is edited directly, the legacy system_prompt column remains stale, and the edit form still posts that stale column value for every save. This new sync call then copies the stale system_prompt back into data["markdown_source"], so changing an unrelated setting such as model/API key through update_ai silently erases the profile-authored prompt. Fresh evidence beyond the earlier stale-reader comments: the edit_ai form is still bound to system_prompt, and this added sync writes that posted value back to the canonical profile prompt.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified in code — this is real. edit_ai bound form.text_area :system_prompt to the legacy column, so directly editing the profile Creative (which the read path, effective_system_prompt, treats as canonical) left the column stale, and any update_ai save re-synced that stale value into data["markdown_source"], clobbering the profile-authored prompt.

Fixed in eeeed34a:

  • The form now pre-fills from effective_system_prompt (canonical markdown_source), so an unrelated setting change re-posts the current prompt as a no-op instead of the stale column.
  • While verifying I found a related write-on-read: effective_system_prompt called profile_creative (find-or-create), so a hot-path prompt read lazily created a profile Creative (+Topic), and re-rendering the form after a failed save crashed on create! with a blank in-memory name. Switched the read path to a find-only profile_creative_if_present; the create variant stays only on the write path (sync_profile_system_prompt!).
  • Regression tests: form renders the canonical prompt not the stale column; update_ai preserves a directly-edited prompt when re-posting the effective value.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Only sync the profile prompt when it was submitted

Fresh evidence beyond the prior stale-form case: this controller already has routing_expression-only PATCH coverage, so update_ai supports partial settings updates that omit system_prompt. When a profile Creative was edited directly and the legacy column is stale, any such partial save still calls sync_profile_system_prompt!, which copies the stale column back over data["markdown_source"] and erases the canonical profile prompt; guard this sync on system_prompt being present in the submitted params or sync from the effective profile value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — real, and fixed as suggested in b5d59bc3. The controller already has routing_expression-only PATCH coverage, and such a partial save left the legacy system_prompt column at its stored (possibly stale) value while sync_profile_system_prompt! still ran, copying it back over the canonical data["markdown_source"].

Now the sync is gated on the param actually being submitted:

@user.sync_profile_system_prompt! if ai_params.key?(:system_prompt)

A full form submit still includes system_prompt (even when cleared → ""), so the clear path is preserved; a partial PATCH that omits it no longer touches the profile prompt. Regression test added: update_ai does not clobber the canonical prompt on a PATCH that omits system_prompt.

redirect_to edit_ai_user_path(@user), notice: I18n.t("collavre.users.update_ai.success")
else
@available_tools = load_available_tools
Expand Down
25 changes: 24 additions & 1 deletion engines/collavre/app/models/collavre/creative.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ class Creative < ApplicationRecord
# ---------------------------------------------------------------------------
# Reserved metadata key registry — engines register their own namespaces so
# `update_metadata` preserves them without core naming vendor-specific keys.
# "kind" is the discriminator (`inbox`/`profile`/`skill`) that scopes like
# `profiles` query on (`data->>'kind'`); it must be preserved or a metadata
# save that omits it makes the row undiscoverable and a duplicate is created.
# ---------------------------------------------------------------------------
BUILTIN_RESERVED_METADATA_KEYS = %w[markdown_source content_type editor].freeze
BUILTIN_RESERVED_METADATA_KEYS = %w[markdown_source content_type editor kind].freeze

class << self
def registered_reserved_metadata_keys
Expand Down Expand Up @@ -69,6 +72,12 @@ def to_partial_path
# --- Inbox ---
scope :inboxes, -> { where("data->>'kind' = 'inbox'") }

# --- Profile ---
PROFILE_KIND = "profile"
SKILL_KIND = "skill"

scope :profiles, -> { where("data->>'kind' = ?", PROFILE_KIND) }

SYSTEM_TOPIC_NAME = "System"
MAIN_TOPIC_NAME = "Main"
CONTENT_TOPIC_NAME = "Content"
Expand Down Expand Up @@ -135,6 +144,20 @@ def self.inbox_for(user)
)
end

# Find or create the profile creative for a given user.
# Places it as a root creative (no parent) owned by the user.
def self.profile_for(user)
existing = profiles.where(user: user).first

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce a single profile creative per user

When two write paths call profile_for for the same user concurrently, such as the backfill migration running while an agent settings save first syncs that user's profile, both can pass this SELECT before either INSERT commits because there is no database uniqueness constraint for (user_id, data->>'kind' = 'profile'). That leaves duplicate root profile Creatives, and later profile_creative/effective_system_prompt reads an arbitrary .first, so prompt edits can land on one profile while agent execution reads another; add a unique constraint and use create-or-find/retry semantics.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The split-brain hazard is real, but I did not add the suggested DB unique constraint — it isn't portable on this schema and would break CI mid-cutover. The creatives.data column is json (not jsonb), and on Postgres json ->> text is STABLE, not IMMUTABLE. A partial-unique index ON creatives (user_id) WHERE data->>'kind' = 'profile' is rejected by Postgres at schema load (functions in index predicate must be marked IMMUTABLE), so it would red the postgres_schema_load job during the active sqlite→pg cutover. (It passes on SQLite, which is why a local-only green would be a false green.) The same unconstrained SELECT-then-INSERT pattern also predates this PR in inbox_for, so a real constraint should cover inbox+profile uniformly.

Shipped a portable mitigation instead (b5d59bc3): every profile lookup now orders by id, so the write path (profile_for) and the read path (profile_creative_if_present) converge on the oldest profile. A transient race-created duplicate becomes harmless (both edit and execution resolve to the same row) rather than a split brain. Regression test: reads and writes converge on the oldest profile when a duplicate exists.

A hard DB guarantee remains a good follow-up, but it requires migrating data to jsonb (or a stored generated column) first and applying to inbox too — out of scope for this PR, and tracked separately.

return existing if existing
Comment on lines +169 to +171

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep duplicate profile creatives from remaining editable

Fresh evidence beyond the earlier duplicate-profile thread: deterministic .order(:id).first only makes helper-based reads/writes choose the oldest profile, but the normal Creative tree still lists all owned root rows (IndexQuery#handle_root_query) and CreativesController#update edits the row identified by the request. If a race creates a newer duplicate, an agent can still edit that visible duplicate directly while effective_system_prompt continues reading the oldest one, so prompt edits can still land on a profile that execution ignores; hide/merge duplicates or prevent creation rather than relying only on ordered lookup.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — real, and fixed at the root in 1d6e5a8e. You're right that .order(:id).first only makes the helper read/write paths converge: IndexQuery#handle_root_query still lists every owned root row and CreativesController#update edits the row in the request, so a race-created duplicate stayed directly editable while effective_system_prompt kept reading the oldest.

Rather than hide/merge duplicates, this now prevents them: a partial unique index on creatives(user_id) WHERE data->>'kind' = 'profile', and profile_for creates via create_or_find_by! so a lost create race resolves to the surviving row.

Correcting the record from the earlier duplicate-profile thread: I had declined this exact index claiming json ->> is STABLE and would be rejected at schema load. That was wrong. json_object_field_text (->>) is IMMUTABLE on Postgres (provolatile = 'i', verified on pg14 and pg16), so the partial predicate is accepted — the postgres_schema_load CI job on this commit is the proof, and it needs no jsonb migration on the current json column. (My earlier reasoning had conflated ->> with the genuinely-nonportable SQLite json_extract.)

Migration collapses any pre-index duplicate onto the oldest profile first. Regression tests: the DB rejects a second profile creative for the same user; create_or_find_by resolves a lost race to the surviving profile.


create!(
description: user.name.to_s,
data: { "kind" => PROFILE_KIND },
user: user,
progress: 0.0
)
end

attr_accessor :filtered_progress

belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
Expand Down
36 changes: 36 additions & 0 deletions engines/collavre/app/models/collavre/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class User < ApplicationRecord
self.table_name = "users"

include HasInboxCreative
include HasProfileCreative

has_many :user_themes, class_name: "Collavre::UserTheme", dependent: :destroy

Expand Down Expand Up @@ -207,6 +208,41 @@ def ai_user?
llm_vendor.present?
end

# Mirror the agent's system prompt into its profile creative as Markdown,
# so the raw prompt lands losslessly in data["markdown_source"] — the
# canonical home for the prompt. Writing the prompt straight into
# `description` would run it through the HTML sanitizer (Describable), which
# strips <thinking>/<tool_call> tags and entity-escapes < > &, corrupting
# every prompt; the derived `description` is only a rendered display view.
# The system_prompt column is legacy, pending removal.
# No-op for humans and for prompt-less agents (profile keeps its name seed).
def sync_profile_system_prompt!
return unless ai_user?
creative = profile_creative
if system_prompt.present?
return if creative.data&.dig("markdown_source") == system_prompt
creative.update!(content_type_input: "markdown", markdown_source: system_prompt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve prompt source instead of Markdown-rewriting it

When an agent prompt contains Markdown data-URI image syntax, such as an instruction/example ![x](data:image/png;base64,...) outside a code span, this routes the prompt through Describable's Markdown save path, which rewrites data-URI images into Active Storage blob URLs before storing data["markdown_source"]. Since effective_system_prompt later sends that stored source to the LLM, the agent receives mutated instructions rather than the prompt text the user entered; the backfill migration can also create blobs while rewriting existing prompts. Store the canonical prompt source without the editor-specific data-URI rewrite, and derive only the display HTML separately.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified in code — real, fixed in ea5f94b1. sync_profile_system_prompt! writes the prompt via the Markdown save path (content_type_input: "markdown"), and convert_markdown_to_html runs MarkdownConverter.rewrite_data_uri_images on any inline ![](data:image/...) outside a code span, uploading it to a blob and storing the blob-path version in data["markdown_source"]. Since effective_system_prompt sends that source straight to the LLM, the agent got a mutated prompt, and the backfill created blobs while rewriting existing prompts. (Code spans were already protected by with_code_protected, so only bare inline data URIs were affected.)

Fix keeps markdown_source byte-for-byte what the user entered: added an opt-in skip_data_uri_rewrite flag on Describable (default behavior unchanged for every existing editor caller — the rewrite still prevents duplicate blobs on rich-editor re-render) and set it only in the prompt-sync path. The derived description HTML still renders the data URI inline. On repeated full-form saves the stored source now equals the column value, so no rewrite fires and no blobs are created.

Regression test: a prompt with an inline data-URI image round-trips through sync and effective_system_prompt verbatim, asserting no /rails/active_storage/blobs or /public-assets/blobs path appears.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip MCP extraction for profile prompts

When an agent prompt contains a fenced example or instruction block with extend ToolMeta, this profile sync saves it as a normal Creative description, so Creative's existing saved_change_to_description? callback enqueues UpdateMcpToolsJob; McpService#update_from_creative then registers any code block containing that marker as an MCP tool and creates an approval comment. During the prompt backfill, AI-settings sync, or direct profile edit, prompt examples can therefore create/replace real tools even though the profile is just the agent's system prompt; exclude data["kind"] == "profile" creatives from MCP extraction/enqueueing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified in code — real, fixed in b2359a5a. A profile creative's description is the agent's system prompt, but Creative's after_save :mark_mcp_tools_sync_pending, if: :saved_change_to_description? fed every description change into UpdateMcpToolsJobMcpService#update_from_creative, which registers any <pre>/<code> block containing extend ToolMeta as a real, approval-gated MCP tool. So a fenced tool-definition example in a prompt would create/replace tools on backfill, AI-settings sync, or a direct profile edit.

Gated at the model (the right altitude, matching the profile data-URI fix): added Creative#profile? (data->>'kind' == 'profile') and mark_mcp_tools_sync_pending now returns early for profiles, so no job is ever enqueued. Defense-in-depth: McpService#update_from_creative also returns early when creative.effective_origin.profile?, so a direct service/job call can't register a tool either. Non-profile creatives still extract tools unchanged.

Regression tests: (1) update_from_creative skips a profile creative whose description contains extend ToolMeta (no McpTool created); (2) saving a profile description does not enqueue UpdateMcpToolsJob; (3) end-to-end through sync_profile_system_prompt! and a direct profile_creative.update! — both leave no tool registered. Verified RED without the guards (the prompt example was registered as prompt_example_tool), GREEN with them.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — real, fixed in b2359a5a. A profile Creative's description is the agent's rendered system prompt, and Creative's saved_change_to_description? callback fed every description change into McpService, so backfill, AI-settings sync, or a direct profile edit could register/replace an approval-gated MCP tool from a fenced extend ToolMeta example in the prompt.

Fixed at two layers:

  • Model gate: added Creative#profile? (data["kind"] == "profile") and skip the UpdateMcpToolsJob enqueue for profile creatives on description change.
  • Defense-in-depth: early return in McpService#update_from_creative for profile creatives, so even a manually-enqueued job is a no-op.

Regression tests cover the service guard, the enqueue gate, and end-to-end sync/direct-edit paths.

elsif creative.data&.dig("markdown_source").present?
# The prompt was cleared. Demote out of markdown mode so the stale
# markdown_source is dropped and render falls back to the (now-blank)
# column — matching the pre-profile behavior where blanking the prompt
# took effect immediately. Also reseed `description` back to the name
# seed so the old rendered prompt doesn't stay visible/searchable on the
# owned profile root. Without this, the old prompt stays authoritative.
creative.update!(content_type_input: "html", description: name.to_s)
end
end

# The effective system prompt for all read consumers. The canonical prompt
# lives in the profile creative's data["markdown_source"]; the legacy
# `system_prompt` column is the fallback for rows not yet backfilled. Every
# prompt reader (orchestration matching, type classification, typo agent,
# admin view) must route here so that editing the profile creative directly
# takes effect everywhere, not just in AiAgentService.
def effective_system_prompt
profile_creative&.data&.dig("markdown_source").presence || system_prompt
end

def claude_channel_agent?
llm_model == "claude-code"
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module Collavre
module HasProfileCreative
extend ActiveSupport::Concern

included do
after_create_commit :create_profile_creative
end

# Returns the user's profile creative, creating one if it doesn't exist.
def profile_creative
Collavre::Creative.profile_for(self)
end

private

def create_profile_creative
Collavre::Creative.profile_for(self)
rescue StandardError => e
Rails.logger.error("[HasProfileCreative] Failed to create profile for user #{id}: #{e.message}")
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ module AgentTypeClassifier
# Returns the agent type string ("developer", "pm", "qa", "researcher",
# "marketer", "planner") or "agent" as the default when nothing matches.
def classify(user)
prompt = user.system_prompt.to_s.downcase
prompt = user.effective_system_prompt.to_s.downcase
case prompt
when /developer|개발/ then "developer"
when /pm|project.?manager|프로젝트/ then "pm"
Expand Down
7 changes: 6 additions & 1 deletion engines/collavre/app/services/collavre/ai_agent_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,13 @@ def prepare_rendering_context
end

def render_system_prompt(rendering_context)
# The prompt lives losslessly in the profile creative's markdown_source
# (data["markdown_source"]); `description` is the sanitized rendered view
# and would corrupt tags/angle-brackets, so never read it here. Fall back
# to the legacy system_prompt column for rows not yet backfilled.
template = @agent.effective_system_prompt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include profile edits in session context invalidation

When a stateful agent has already replied in a topic, SessionContextResolver sends only the trigger message whenever MessageBuilder#context_changed_since_last_reply? is false; that check still only compares @agent.updated_at plus injected creatives. With the prompt now coming from profile_creative.data["markdown_source"], a direct profile Creative edit updates only the Creative row, not the user row, so subsequent stateful calls keep using the old session instructions and this newly resolved prompt is dropped as nil. Include the selected profile Creative's updated_at in the context-change check so direct prompt edits force a full payload.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified in code — real, fixed in f1ecee5f. context_changed_since_last_reply? only compared @agent.updated_at (the User row) and the rendered creative subtree. The canonical prompt now lives in the profile creative's data["markdown_source"], which sits outside that subtree, and a direct edit bumps only the Creative row — so a stateful agent that had already replied kept the old session instructions and this newly resolved prompt was dropped as nil.

Now the check also includes the profile creative's updated_at:

profile_updated_at = @agent.profile_creative_if_present&.updated_at
prompt_changed = profile_updated_at.present? && profile_updated_at > last_reply_at
creative_changed || agent_changed || prompt_changed

profile_creative_if_present is the find-only reader (no write on this hot path). Regression test: a direct profile prompt edit after the last reply forces context_changed, with the agent User row asserted untouched.

rendered = AiSystemPromptRenderer.new(
template: @agent.system_prompt,
template: template,
context: rendering_context
).render

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,15 @@ def calculate_keyword_score(agent, message_text)
def extract_expertise_text(agent)
texts = []

# From agent's system_prompt (primary source for AI agents)
texts << agent.system_prompt if agent.respond_to?(:system_prompt) && agent.system_prompt.present?
# From agent's system_prompt (primary source for AI agents). Route
# through effective_system_prompt so a directly-edited profile creative
# is matched on, falling back to the legacy column for other agent types.
texts << agent.effective_system_prompt if agent.respond_to?(:effective_system_prompt) && agent.effective_system_prompt.present?
texts << agent.system_prompt if !agent.respond_to?(:effective_system_prompt) && agent.respond_to?(:system_prompt) && agent.system_prompt.present?

# From AI agent profile (for nested ai_agent association)
if agent.respond_to?(:ai_agent) && agent.ai_agent.present?
texts << agent.ai_agent.system_prompt if agent.ai_agent.respond_to?(:system_prompt)
texts << agent.ai_agent.effective_system_prompt if agent.ai_agent.respond_to?(:effective_system_prompt)
texts << agent.ai_agent.description if agent.ai_agent.respond_to?(:description)
end

Expand Down
2 changes: 1 addition & 1 deletion engines/collavre/app/services/collavre/typo_corrector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def build_client
AiClient.new(
vendor: agent&.llm_vendor.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_VENDOR", "gemini"),
model: agent&.llm_model.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_MODEL", "gemini-3.1-flash-lite"),
system_prompt: agent&.system_prompt.presence || FALLBACK_SYSTEM_PROMPT,
system_prompt: agent&.effective_system_prompt.presence || FALLBACK_SYSTEM_PROMPT,
llm_api_key: agent&.llm_api_key,
gateway_url: agent&.gateway_url,
# Vendor adapters (e.g. OpenClaw) resolve the gateway/key from the context
Expand Down
2 changes: 1 addition & 1 deletion engines/collavre/app/views/collavre/users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<dd class="col-sm-9"><%= @user.llm_model %></dd>

<dt class="col-sm-3"><%= t('collavre.users.new_ai.system_prompt_label') %></dt>
<dd class="col-sm-9"><pre><%= @user.system_prompt %></pre></dd>
<dd class="col-sm-9"><pre><%= @user.effective_system_prompt %></pre></dd>
</dl>
</div>
<% else %>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class BackfillProfileCreatives < ActiveRecord::Migration[8.1]
disable_ddl_transaction!

def up
Collavre::User.find_each(batch_size: 200) do |user|
Collavre::Creative.profile_for(user)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add kind column before running profile backfills

On an upgrade from main, this migration runs at 20260721000000, but the creatives.kind column is not added until 20260721000010; the current Collavre::Creative model already has the IndexedJsonColumns before_save callback that writes self["kind"] from data. So each profile_for insert here tries to write an unknown column, gets rescued/logged, and the migration succeeds without creating profiles; the following prompt backfill has the same ordering problem. Move the promoted-column migration before these backfills or use a migration-local model that doesn't touch kind until the column exists.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — real P1, fixed in 95c573b0. Confirmed the mechanism: IndexedJsonColumns installs a before_save that writes self["kind"] = data["kind"] on every Creative save, and the profile backfills (20260721000000/000001) save through the full model — but kind was only added by 20260721000010. On an upgrade from main the backfills run first, each profile_for save raises ActiveModel::MissingAttributeError, the migration's rescue swallows it, and the migration "succeeds" creating zero profiles.

Fixed by moving the column promotion into a new migration 20260720235959_add_kind_column_to_creatives that runs before the backfills; 20260721000010 now only collapses duplicates and adds the unique index. The new migration guards add_column (unless column_exists?, idempotent for machines that applied an earlier revision of this PR) and calls reset_column_information so an already-loaded Creative model picks up the column.

schema.rb is unchanged — the column and index are already present and the max version is still 20260721000010, so postgres_schema_load is unaffected. That's also exactly why CI never caught this: it loads schema.rb and never replays migration order.

Validation (replayed the sequence on a scratch SQLite DB): from-zero migrate runs clean (no MissingAttributeError); with the kind column present — the post-fix backfill state — profile_for creates the profile (0 → 1) with kind populated ("profile", matching data["kind"]) and markdown_source preserved verbatim (incl. <thinking>). Regression test the kind column is added before any migration that backfills profiles locks the ordering invariant so a future re-merge/renumber fails loudly.

rescue => e
Rails.logger.error("[BackfillProfileCreatives] user #{user.id}: #{e.message}")
end
end

def down
# No-op: profiles are idempotent and safe to keep.
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class BackfillProfileDescriptions < ActiveRecord::Migration[8.1]
disable_ddl_transaction!

def up
Collavre::User.find_each(batch_size: 200) do |user|
user.sync_profile_system_prompt!
rescue => e
Rails.logger.error("[BackfillProfileDescriptions] user #{user.id}: #{e.message}")
end
end

def down
# No-op: descriptions are safe to keep.
end
end
10 changes: 10 additions & 0 deletions engines/collavre/test/fixtures/topics.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
_fixture:
model_class: Collavre::Topic

# Intentionally empty. Registering the topics table as fixture-managed makes
# Rails DELETE FROM topics during fixture loading, before the whole-DB
# foreign-key check runs. Non-transactional (system) tests can create a user
# whose profile creative (Collavre::HasProfileCreative) is committed together
# with a main topic; the raw fixture DELETE of creatives bypasses
# dependent: :destroy and would otherwise leave that topic orphaned, tripping
# check_all_foreign_keys_valid! for every subsequent fixture load.
43 changes: 43 additions & 0 deletions engines/collavre/test/models/collavre/creative_profile_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# engines/collavre/test/models/collavre/creative_profile_test.rb
require "test_helper"

module Collavre
class CreativeProfileTest < ActiveSupport::TestCase
setup { @user = Collavre::User.create!(name: "Ann", email: "ann@example.com", password: "password123") }

test "profile_for creates a profile creative owned by the user" do
profile = Collavre::Creative.profile_for(@user)
assert_equal "profile", profile.data["kind"]
assert_equal @user.id, profile.user_id
end

test "profile_for is idempotent" do
first = Collavre::Creative.profile_for(@user)
assert_no_difference -> { Collavre::Creative.profiles.where(user: @user).count } do
assert_equal first.id, Collavre::Creative.profile_for(@user).id
end
end

test "kind discriminator is a reserved metadata key" do
# `kind` scopes profile/inbox/skill discovery; it must survive a metadata
# save that omits it, or the profile becomes undiscoverable and duplicates.
assert_includes Collavre::Creative.reserved_metadata_keys, "kind"
end

test "preserving reserved keys keeps a profile discoverable after a metadata save" do
profile = Collavre::Creative.profile_for(@user)
# Simulate update_metadata's preservation loop with a payload omitting kind.
incoming = { "theme" => "dark" }
Collavre::Creative.reserved_metadata_keys.each do |key|
if profile.data.key?(key)
incoming[key] = profile.data[key]
else
incoming.delete(key)
end
end
profile.update!(data: incoming)
assert_equal profile.id, Collavre::Creative.profile_for(@user).id
assert_equal "profile", profile.reload.data["kind"]
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
require "test_helper"

module Collavre
class UserProfileCreativeTest < ActiveSupport::TestCase
test "human user gets a profile creative on create" do
u = Collavre::User.create!(name: "Human", email: "h@example.com", password: "password123")
assert_equal "profile", u.profile_creative.data["kind"]
end

test "AI agent also gets a profile creative on create" do
a = Collavre::User.create!(name: "Bot", email: "bot@example.com", password: "password123", llm_vendor: "google")
assert a.ai_user?
assert_equal "profile", a.profile_creative.data["kind"]
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
require "test_helper"

module Collavre
class UserProfileSystemPromptTest < ActiveSupport::TestCase
test "sync mirrors the agent system prompt into the profile markdown_source" do
agent = Collavre::User.create!(name: "NamedBot", email: "syncbot1@ai.local",
password: "password123", llm_vendor: "google",
system_prompt: "REAL PROMPT")
# after_create seeds the profile description with the name — the bug we fix.
assert_equal "NamedBot", agent.profile_creative.description
agent.sync_profile_system_prompt!
assert_equal "REAL PROMPT", agent.profile_creative.reload.data["markdown_source"]
end

test "sync stores prompts with tags and angle brackets losslessly" do
# Prompts routinely contain <thinking>, tool tags, and < > &. These MUST
# survive verbatim in markdown_source; the sanitized `description` would
# strip/entity-escape them, which is exactly why the prompt is NOT stored
# in description.
prompt = "Wrap reasoning in <thinking>...</thinking>. " \
"When count < 5 and result > 3 emit <tool_call>x</tool_call>. Use JSON & XML."
agent = Collavre::User.create!(name: "TagBot", email: "tagbot@ai.local",
password: "password123", llm_vendor: "google",
system_prompt: prompt)
agent.sync_profile_system_prompt!
assert_equal prompt, agent.profile_creative.reload.data["markdown_source"]
end

test "sync is idempotent and does not re-write an unchanged prompt" do
agent = Collavre::User.create!(name: "IdemBot", email: "idembot@ai.local",
password: "password123", llm_vendor: "google",
system_prompt: "STABLE PROMPT")
agent.sync_profile_system_prompt!
before = agent.profile_creative.reload.updated_at
agent.sync_profile_system_prompt!
assert_equal before, agent.profile_creative.reload.updated_at
end

test "clearing the system prompt drops the stale markdown_source" do
agent = Collavre::User.create!(name: "ClearBot", email: "clearbot@ai.local",
password: "password123", llm_vendor: "google",
system_prompt: "FIRST PROMPT")
agent.sync_profile_system_prompt!
assert_equal "FIRST PROMPT", agent.profile_creative.reload.data["markdown_source"]

# Admin blanks the prompt textarea — the old prompt must NOT stay authoritative.
agent.update!(system_prompt: "")
agent.sync_profile_system_prompt!
assert_nil agent.profile_creative.reload.data&.dig("markdown_source")

# The old rendered prompt must not linger as the visible profile description;
# it is reseeded back to the name so it isn't searchable/shown on the root.
assert_equal "ClearBot", agent.profile_creative.reload.description

svc = Collavre::AiAgentService.allocate
svc.instance_variable_set(:@agent, agent)
svc.instance_variable_set(:@creative, nil)
refute_includes svc.send(:render_system_prompt, {}), "FIRST PROMPT"
end

test "effective_system_prompt prefers profile markdown_source over the legacy column" do
agent = Collavre::User.create!(name: "EffBot", email: "effbot@ai.local",
password: "password123", llm_vendor: "google",
system_prompt: "LEGACY COLUMN")
# Before sync the column is the only source.
assert_equal "LEGACY COLUMN", agent.effective_system_prompt
# A direct profile edit (no column write) must take effect for all readers.
agent.profile_creative.update!(content_type_input: "markdown", markdown_source: "EDITED IN PROFILE")
assert_equal "EDITED IN PROFILE", agent.reload.effective_system_prompt
end

test "sync is a no-op for human users" do
human = Collavre::User.create!(name: "Human", email: "human1@example.com", password: "password123")
human.sync_profile_system_prompt!
assert_nil human.profile_creative.data&.dig("markdown_source")
assert_equal "Human", human.profile_creative.description
end

test "sync is a no-op when the agent has no system prompt" do
agent = Collavre::User.create!(name: "Blanky", email: "syncbot2@ai.local",
password: "password123", llm_vendor: "google")
agent.sync_profile_system_prompt!
assert_nil agent.profile_creative.data&.dig("markdown_source")
assert_equal "Blanky", agent.profile_creative.description
end
end
end
Loading