# Trigger Agent Mode Flag on Button Reply Buttons — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let a journey author tick "Trigger Agent Mode" on exactly one button of a Button Reply node, and persist that button's id through the raw flowchart, the journey save payload, the compiled template's `setting_json`, and a new `channel_templates.agent_mode_button_id` column.

**Architecture:** The per-button boolean `triggerAgentMode` in React Flow node data is the source of truth (it round-trips through the raw flowchart for free, so editor reload and version restore need no work). Because at most one button per node may be flagged, everything downstream collapses to one scalar — the flagged button's id — so the shared `toButtonPayload()` helper and its ~40 seeder callers stay untouched. The flag has **no runtime effect**; nothing reads it yet.

**Tech Stack:** Laravel 8 (PHP 8.1 under DDEV), MySQL, React Flow in `resources/js/flow/flow.jsx`, Laravel Mix, PHPUnit.

**Spec:** `docs/superpowers/specs/2026-07-31-button-reply-agent-mode-design.md`

## Global Constraints

- **No commits.** Do not run `git commit`, `git push`, or open a PR at any point. Leave changes in the working tree.
- **Never run `npm run development` or `npx mix`.** This repo commits *production* front-end builds into `public/js` / `public/css` with `mix-manifest.json` version hashes; a dev build clobbers every bundle app-wide. Use `npm run production` only.
- **PHP runs inside DDEV.** The database host is `db`, unreachable from the host shell. Prefix every PHP/artisan/phpunit command with `ddev exec` (or use `ddev artisan`). `npm` runs on the host.
- **Tests hit `db_test`, never `db`.** `phpunit.xml` sets `APP_ENV=testing`, so Laravel loads `.env.testing` (`DB_DATABASE="db_test"`). `RefreshDatabase` wipes whatever it is pointed at — verify the env before the first test run (Task 2, Step 1).
- **Do not touch `resources/js/flow/blast.jsx`.** Its duplicate `ButtonReplyNode` (`:2282`) and the `seedTemplateBlast()` path are out of scope.
- **No runtime behavior.** No changes to `WaNotifications`, `WaWebhookController`, `ChannelService`, or the chat inbox.
- **JSON naming is camelCase (`agentModeButtonId`) at every layer; the DB column alone is snake_case (`agent_mode_button_id`).** This follows the existing `imageUrl` precedent.

---

### Task 1: Add the `agent_mode_button_id` column

**Files:**
- Create: `database/migrations/2026_07_31_000000_add_agent_mode_button_id_to_channel_templates_table.php`
- Modify: `app/Models/ChannelTemplates.php:94-108` (`$fillable`)

**Interfaces:**
- Consumes: nothing.
- Produces: `channel_templates.agent_mode_button_id` — nullable `unsignedTinyInteger`, mass-assignable on `App\Models\ChannelTemplates`. `null` means no button on this template triggers agent mode. Task 2 writes and reads it.

- [ ] **Step 1: Write the migration**

Create `database/migrations/2026_07_31_000000_add_agent_mode_button_id_to_channel_templates_table.php`. Class-based (Laravel 8 style), matching `2026_06_01_000001_add_wa_error_to_channel_templates_table.php`:

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AddAgentModeButtonIdToChannelTemplatesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('channel_templates', function (Blueprint $table) {
            // Id of the single button flagged "Trigger Agent Mode" in the journey
            // editor; null when no button on this template is flagged.
            $table->unsignedTinyInteger('agent_mode_button_id')->nullable()->after('setting_json');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('channel_templates', function (Blueprint $table) {
            $table->dropColumn('agent_mode_button_id');
        });
    }
}
```

- [ ] **Step 2: Add the column to `$fillable`**

In `app/Models/ChannelTemplates.php`, inside `$fillable` (starts at `:94`), add the new key immediately after `'setting_json',`:

```php
    protected $fillable = [
        'locale',
        'slug',
        'type',
        'description',
        'setting_json',
        'agent_mode_button_id',
        'wa_status',
        'wa_template_id',
        'wa_displayed_status',
        'wa_error',
        'wa_template_category',
        'validate_json',
        'workflow_interuptable',
        'workflow_interupted_resumable',
    ];
```

Do **not** add a `$casts` entry — MySQL returns the tinyint as an int/string and the column is only ever compared or written as a scalar. Adding a cast is unnecessary.

- [ ] **Step 3: Run the migration on the dev database**

Run: `ddev artisan migrate`
Expected: output includes `Migrating: 2026_07_31_000000_add_agent_mode_button_id_to_channel_templates_table` then `Migrated:`. This is an additive nullable column — it does not touch existing data.

- [ ] **Step 4: Verify the column exists**

Run: `ddev mysql -e "SHOW COLUMNS FROM channel_templates LIKE 'agent_mode_button_id';"`
Expected: one row, `Type` = `tinyint unsigned`, `Null` = `YES`, `Default` = `NULL`.

- [ ] **Step 5: Do not commit**

Leave the changes staged-or-unstaged in the working tree. Per the global constraints, no commit.

---

### Task 2: Carry the flag from the journey payload into `setting_json` and the column

**Files:**
- Modify: `app/Http/Controllers/Journey/JourneyController.php:3082-3107` (`seedTemplate()`, button-reply branch)
- Modify: `database/seeders/Traits/HasTemplateImportTrait.php:37-39` (existing-row branch of `import()`)
- Create: `tests/Feature/Journey/AgentModeButtonPersistenceTest.php`

**Interfaces:**
- Consumes: `channel_templates.agent_mode_button_id` from Task 1.
- Produces:
  - Template arrays built by `seedTemplate()` for `type === 'button-reply'` now carry `'agent_mode_button_id' => int|null` alongside `'setting_json'`.
  - Each locale block of a button-reply `setting_json` carries `'agentModeButtonId' => int` **only when a button is flagged** (key absent otherwise).
  - `import()` keeps the column in sync on re-import, including clearing it back to `null`.
  - The journey-editor payload key this reads is `settings[<locale>]['agentModeButtonId']`, produced by Task 3. Task 3 must use exactly that name.

- [ ] **Step 1: Confirm the test environment points at `db_test`**

Run: `ddev exec grep DB_DATABASE .env.testing`
Expected: `DB_DATABASE="db_test"`

This matters because the test below uses `RefreshDatabase`, which drops and re-migrates every table in the configured database. If this prints anything other than `db_test`, **stop and ask** — do not run the test against the shared dev `db`.

- [ ] **Step 2: Write the failing test**

Create `tests/Feature/Journey/AgentModeButtonPersistenceTest.php`. The channel-seeding helper mirrors `tests/Feature/Chat/RecipientChatReplyTest.php:152`. The test calls the seeder trait's `import()` directly — that is the single funnel every journey save goes through to write template rows.

```php
<?php

namespace Tests\Feature\Journey;

use App\Models\ChannelTemplates;
use App\Models\Channels;
use App\Models\User;
use App\Models\WaAccounts;
use App\Skale\Enums\TemplateTypeEnum;
use Database\Seeders\Traits\HasTemplateImportTrait;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AgentModeButtonPersistenceTest extends TestCase
{
    use RefreshDatabase;
    use HasTemplateImportTrait;

    /** @test */
    public function it_persists_the_agent_mode_button_id_on_a_new_template()
    {
        $channel = $this->seedChannel();

        $this->import([$this->buttonReplyTemplate(2)], $channel);

        $template = ChannelTemplates::where('slug', 'test_buttons')->firstOrFail();
        $this->assertSame(2, (int) $template->agent_mode_button_id);
        $this->assertSame(2, $template->setting_json['en']['agentModeButtonId']);
    }

    /** @test */
    public function it_clears_the_agent_mode_button_id_when_the_flag_is_removed()
    {
        // Re-importing the same slug is what a journey re-save does. import()
        // refreshes only setting_json on an existing row, so without an explicit
        // assignment the column would keep the stale id forever.
        $channel = $this->seedChannel();

        $this->import([$this->buttonReplyTemplate(2)], $channel);
        $this->import([$this->buttonReplyTemplate(null)], $channel);

        $this->assertSame(1, ChannelTemplates::where('slug', 'test_buttons')->count());

        $template = ChannelTemplates::where('slug', 'test_buttons')->firstOrFail();
        $this->assertNull($template->agent_mode_button_id);
        $this->assertArrayNotHasKey('agentModeButtonId', $template->setting_json['en']);
    }

    /**
     * A button-reply template array in the exact shape seedTemplate() emits.
     * The flag keys are omitted entirely when no button is flagged.
     */
    protected function buttonReplyTemplate(?int $agentModeButtonId): array
    {
        $en = [
            'text' => 'Pick one',
            'button_reply' => [
                ['id' => 1, 'old_id' => 1, 'title' => 'Yes'],
                ['id' => 2, 'old_id' => 2, 'title' => 'Talk to agent'],
            ],
        ];

        if ($agentModeButtonId !== null) {
            $en['agentModeButtonId'] = $agentModeButtonId;
        }

        return [
            'type' => TemplateTypeEnum::BTN_REPLY,
            'slug' => 'test_buttons',
            'setting_json' => ['en' => $en],
            'agent_mode_button_id' => $agentModeButtonId,
        ];
    }

    /**
     * Minimum graph for import(): a channel bound to a WABA.
     */
    protected function seedChannel(): Channels
    {
        $user = User::factory()->create();

        $waba = WaAccounts::create([
            'name' => 'Test WABA',
            'wa_phone_number_id' => '1234567890',
            'wa_access_token' => 'test-token',
            'display_phone_number' => '60100000000',
        ]);

        return Channels::create([
            'name' => 'Test Channel',
            'wa_account_id' => $waba->id,
            'date_from' => now(),
            'created_by' => $user->id,
        ]);
    }
}
```

- [ ] **Step 3: Run the test to verify it fails**

Run: `ddev exec vendor/bin/phpunit tests/Feature/Journey/AgentModeButtonPersistenceTest.php`
Expected: 1 passed, 1 failed.

- `it_persists_the_agent_mode_button_id_on_a_new_template` **passes** already — the new-row path in `import()` does `new ChannelTemplates($template)`, and Task 1 made the column mass-assignable. This test exists to guard that `$fillable` entry against regression.
- `it_clears_the_agent_mode_button_id_when_the_flag_is_removed` **fails** on `assertNull($template->agent_mode_button_id)` receiving `2` — the existing-row path refreshes only `setting_json`, so the stale id survives.

If instead you get `Unknown column 'agent_mode_button_id'`, Task 1's migration did not run on `db_test`. `RefreshDatabase` runs migrations itself, so re-check that the migration file exists and is syntactically valid.

- [ ] **Step 4: Re-apply the column on re-import**

In `database/seeders/Traits/HasTemplateImportTrait.php`, in the existing-row branch of `import()` (`:37`):

```php
      if ($channel_template) {
        $channel_template->setting_json = $template['setting_json'];
        // Only setting_json is refreshed for an existing row, so any other
        // journey-authored field has to be re-applied explicitly or it goes
        // stale — unchecking the box in the editor would never clear it.
        $channel_template->agent_mode_button_id = $template['agent_mode_button_id'] ?? null;
      } else {
        $channel_template = new ChannelTemplates($template);

        $channel_template->channel()->associate($channel);
      }
```

The `?? null` keeps the ~40 seeders that never set the key at `null`, which is correct for them.

- [ ] **Step 5: Run the test to verify it passes**

Run: `ddev exec vendor/bin/phpunit tests/Feature/Journey/AgentModeButtonPersistenceTest.php`
Expected: `OK (2 tests, ...)`

- [ ] **Step 6: Carry the flag in `seedTemplate()`**

In `app/Http/Controllers/Journey/JourneyController.php`, replace the button-reply branch (`:3082-3107`) with:

```php
            if ($component['type'] === 'button-reply') {

                // imageUrl is a single (non-language) value, stored on the en block by
                // the flow serialization. Copy it into every locale so it survives the
                // send-time locale reduction (setting_json is scoped to one locale).
                $imageUrl = $component['settings']['en']['imageUrl'] ?? '';

                // Same idea for the agent-mode flag — one scalar per node, the id of
                // the button ticked in the editor. Read it from whichever locale block
                // carries it: the editor drops locale blocks that are fully empty, so
                // an en-less node would otherwise lose the flag.
                $agentModeButtonId = collect($component['settings'])
                    ->pluck('agentModeButtonId')
                    ->first(fn ($id) => $id !== null);

                $text = [];
                foreach ($component['settings'] as $key => $lang) {
                    $replies = collect($lang['button_reply'])->map(function ($item) {
                        return $item['title'] ?? '';
                    })->toArray();
                    $text[$key] = [
                        'text' => $lang['text'] ?? '',
                        'button_reply' => $this->toButtonPayload($replies),
                    ];
                    if ($imageUrl !== '') {
                        $text[$key]['imageUrl'] = $imageUrl;
                    }
                    if ($agentModeButtonId !== null) {
                        $text[$key]['agentModeButtonId'] = $agentModeButtonId;
                    }
                }

                $templates->add([
                    'type' => TemplateTypeEnum::BTN_REPLY,
                    'slug' => $slugPrefix.Str::slug($component['slug'], '_'),
                    'setting_json' => $text,
                    'agent_mode_button_id' => $agentModeButtonId,
                ]);
```

Nothing else in the branch changes. `toButtonPayload()` is untouched, so no seeder is affected.

- [ ] **Step 7: Verify nothing else broke**

Run: `ddev exec vendor/bin/phpunit tests/Unit tests/Feature`
Expected: the suite is green (or fails only in ways already failing before this task — check `git stash`-free by comparing against a baseline run if anything looks pre-existing). In particular `tests/Unit/WaFlows/ButtonReplyContentTest.php` must still pass, since `setting_json` gained a key.

- [ ] **Step 8: Do not commit**

---

### Task 3: The checkbox in the journey editor

**Files:**
- Modify: `resources/js/flow/flow.jsx:1471-2356` (`ButtonReplyNode` — new handler + checkbox)
- Modify: `resources/js/flow/flow.jsx:10222-10229` (serialization of non-language fields)
- Modify: `resources/js/flow/flow.jsx:10438-10447` (primary→target locale move)
- Build output: `public/js/**`, `public/mix-manifest.json` (regenerated, not hand-edited)

**Interfaces:**
- Consumes: `settings[<locale>]['agentModeButtonId']` is the key Task 2's `seedTemplate()` reads. Emit exactly that name.
- Produces: `node.data.button_reply[i].triggerAgentMode` (boolean, at most one `true` per node) in the raw flowchart; `settings[primaryLang].agentModeButtonId` (`int | null`) in the journey save payload.

- [ ] **Step 1: Add the `onAgentModeChange` handler**

In `resources/js/flow/flow.jsx`, insert this immediately after the `onButtonTitleChange` callback ends (`:1632`, the `);` before `const addButtonToList`). It mirrors `onButtonTitleChange`'s state + `setNodes` pattern exactly:

```js
    const onAgentModeChange = useCallback(
        (buttonId, checked) => {
            // ponytail: at most one agent-mode button per node — ticking one
            // clears the others, so downstream only ever carries a single id.
            const updatedButtons = buttons.map((button) => ({
                ...button,
                triggerAgentMode: checked && button.id === buttonId,
            }));
            setButtons(updatedButtons);
            setNodes((nds) =>
                nds.map((node) =>
                    node.id === id
                        ? {
                              ...node,
                              data: {
                                  ...node.data,
                                  text: text,
                                  button_reply: updatedButtons,
                                  isBusinessInitiated,
                                  businessInitiatedText,
                                  activeLanguages,
                                  db_id: data.db_id,
                                  channel_id: data.channel_id,
                                  slug: data.slug,
                              },
                          }
                        : node,
                ),
            );
        },
        [
            id,
            setNodes,
            buttons,
            text,
            isBusinessInitiated,
            businessInitiatedText,
            activeLanguages,
            data.db_id,
            data.channel_id,
            data.slug,
        ],
    );
```

- [ ] **Step 2: Render the checkbox inside each button box**

Still in `ButtonReplyNode`, inside the `buttons.map((button) => ...)` render block. Insert between the close of the per-language title fields map (`:2281`, the `})}` that ends `activeLanguages.map`) and the `<button onClick={() => deleteButtonFromList(button.id)}>` element (`:2283`):

```jsx
                            {/* At most one button per node may trigger agent mode */}
                            <label
                                style={{
                                    display: "block",
                                    fontSize: "12px",
                                    marginBottom: "5px",
                                }}
                            >
                                <input
                                    type="checkbox"
                                    checked={!!button.triggerAgentMode}
                                    onChange={(evt) =>
                                        onAgentModeChange(
                                            button.id,
                                            evt.target.checked,
                                        )
                                    }
                                />
                                {" Trigger Agent Mode"}
                            </label>
```

Result:

```
┌─ Button 1 ──────────────────┐
│ English:                    │
│ [ Talk to agent          ]  │
│ [x] Trigger Agent Mode      │
│ [ Delete Button ]           │
└─────────────────────────────┘
```

Nothing else in the node changes: no new handles, no edge manipulation, no interaction with the Business Initiated checkbox. `deleteButtonFromList` (`:1686`) already renumbers button ids and the flag rides on the button object, so it follows renumbering with no extra code.

- [ ] **Step 3: Serialize the flag into the save payload**

In the node → template serialization, find the button-reply non-language-specific block (`:10222`):

```js
                        // Non-language-specific fields (place under primaryLang)
                        if (node.type === "button-reply") {
                            settings[primaryLang].isBusinessInitiated =
                                data.isBusinessInitiated || false;
                            settings[primaryLang].businessInitiatedText =
                                data.businessInitiatedText || "";
                            settings[primaryLang].imageUrl =
                                data.imageUrl || "";
                        }
```

Add one assignment at the end of that `if` body:

```js
                            settings[primaryLang].agentModeButtonId =
                                data.button_reply?.find(
                                    (b) => b.triggerAgentMode,
                                )?.id ?? null;
```

Do **not** add it to the "duplicate non-language-specific fields to other included languages" block (`:10514`) — `imageUrl` isn't duplicated there either; the PHP side (Task 2) copies the scalar into every locale.

- [ ] **Step 4: Keep the flag when the primary locale is dropped**

The editor deletes locale blocks that are fully empty (`:10551`), and when `primaryLang` isn't among the included locales it moves non-language fields onto `targetLang` (`:10405`). In the button-reply arm of that move (`:10438-10447`), add the same treatment given to `isBusinessInitiated`:

```js
                            if (node.type === "button-reply") {
                                settings[targetLang].isBusinessInitiated =
                                    settings[primaryLang].isBusinessInitiated;
                                settings[targetLang].businessInitiatedText =
                                    settings[primaryLang].businessInitiatedText;
                                settings[targetLang].agentModeButtonId =
                                    settings[primaryLang].agentModeButtonId;
                                delete settings[primaryLang]
                                    .isBusinessInitiated;
                                delete settings[primaryLang]
                                    .businessInitiatedText;
                                delete settings[primaryLang].agentModeButtonId;
                            }
```

Without this, a node with no English content would serialize the flag onto a block that is then deleted. (Task 2's cross-locale read is the second half of this safety net.)

- [ ] **Step 5: Build the assets**

Run: `npm run production`
Expected: Mix completes successfully; `public/js/flow/*.js` and `public/mix-manifest.json` change.

**Never** run `npm run development` / `npx mix` — it replaces every committed production bundle with un-minified builds and strips the manifest version hashes, breaking unrelated pages. If that happens by accident, recover with `git checkout -- public/`.

- [ ] **Step 6: Verify the checkbox end to end by hand**

There is no JS test runner in this repo (`package.json` has only Mix scripts), so this path is verified manually. In the journey editor:

1. Open a journey with a Button Reply node that has at least two buttons.
2. Tick "Trigger Agent Mode" on Button 1, then tick it on Button 2 — Button 1's tick must clear.
3. Untick Button 2 — no button is ticked.
4. Re-tick Button 2, save the journey, reload the page: the tick is still on Button 2.
5. Confirm the compiled row: `ddev mysql -e "SELECT slug, agent_mode_button_id, setting_json FROM channel_templates WHERE type='button_reply' AND agent_mode_button_id IS NOT NULL;"` — the flagged node's slug appears with `agent_mode_button_id = 2` and `agentModeButtonId: 2` inside `setting_json`.
6. Untick it, save again, re-run the query: the row no longer appears (column back to `null`).

Report the actual output of steps 2, 4, and 5 — not just "verified".

- [ ] **Step 7: Do not commit**

Leave everything, including the rebuilt `public/` assets, uncommitted in the working tree.

---

## Out of scope (do not implement)

- Any runtime reaction to the flag: automation suppression, chat-inbox indicator, webhook branching, `WaNotifications` changes.
- The blast editor (`resources/js/flow/blast.jsx`) and `seedTemplateBlast()`.
- Fixing the pre-existing `imageUrl` locale-drop gap for button-reply nodes (`:10514` doesn't duplicate it either). Noticed, deliberately left alone.
- Any `$casts` entry, index, or foreign key on the new column.
