diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f5f6e5..807172e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,5 +73,5 @@ jobs: tools: composer:v2 - name: Install Composer dependencies run: composer install --no-interaction --prefer-dist --no-progress - - name: Run plugin standards check + - name: Run dist plugin standards check run: npm run plugin:check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f989537..8cd0604 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ npm run lint:changed npm run plugin:check ``` -`npm run plugin:check` starts a disposable local WordPress environment via `wp-env`, installs the official Plugin Check plugin, and runs `wp plugin check` against ClawPress. +`npm run plugin:check` builds a distribution zip, starts a disposable local WordPress environment via `wp-env`, installs the official Plugin Check plugin, installs the dist zip under a temporary slug, and runs `wp plugin check` against that packaged artifact. ## Tests diff --git a/README.md b/README.md index 111dff0..5e67aea 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,20 @@ -# ClawPress +=== ClawPress === +Contributors: bradvin +Tags: ai, assistant, admin +Requires at least: 6.9 +Tested up to: 6.9 +Requires PHP: 8.1 +Stable tag: 0.0.2 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +AI assistant for WordPress admins. + +== Description == ![ClawPress Logo](img/clawpress-logo-500x500.png) -The AI for WordPress that actually does things +ClawPress is the AI for WordPress that actually does things. [Preview in WordPress Playground](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/bradvin/clawpress/refs/heads/main/blueprint.json) diff --git a/bin/plugin-check b/bin/plugin-check index a6c9eb1..3704c60 100755 --- a/bin/plugin-check +++ b/bin/plugin-check @@ -6,11 +6,21 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" -echo "Starting wp-env for plugin check..." +DIST_SLUG="clawpress-dist-check" +EXPECTED_SLUG="clawpress" +DIST_ZIP_PATH="/var/www/html/wp-content/plugins/clawpress/dist/clawpress.zip" + +echo "Building plugin zip for dist plugin check..." +npm run plugin-zip + +echo "Starting wp-env for dist plugin check..." npx wp-env start echo "Installing/updating Plugin Check plugin..." npx wp-env run tests-cli wp plugin install plugin-check --activate --force -echo "Running Plugin Check against clawpress..." -npx wp-env run tests-cli wp plugin check clawpress +echo "Installing dist zip into temporary plugin slug..." +npx wp-env run tests-cli -- bash -lc "set -euo pipefail; rm -rf /tmp/plugin-check-dist /var/www/html/wp-content/plugins/${DIST_SLUG}; mkdir -p /tmp/plugin-check-dist; unzip -q ${DIST_ZIP_PATH} -d /tmp/plugin-check-dist; DIST_SOURCE=\$(find /tmp/plugin-check-dist -mindepth 1 -maxdepth 1 -type d | head -n 1); if [[ -z \"\${DIST_SOURCE}\" ]]; then echo 'Unable to locate extracted plugin directory from dist zip.' >&2; exit 1; fi; cp -R \"\${DIST_SOURCE}\" /var/www/html/wp-content/plugins/${DIST_SLUG};" + +echo "Running Plugin Check against dist plugin contents..." +npx wp-env run tests-cli wp plugin check "${DIST_SLUG}" --slug="${EXPECTED_SLUG}" --ignore-codes=trademarked_term diff --git a/build/panel/panel-rtl.css b/build/panel/panel-rtl.css index 2d6cb26..7bed574 100644 --- a/build/panel/panel-rtl.css +++ b/build/panel/panel-rtl.css @@ -1 +1 @@ -.clawpress-mock-panel,.clawpress-panel{--fp-bg:light-dark(#fff,#1d2327);--fp-surface:light-dark(#f6f7f7,#1d2327);--fp-surface-contrast:light-dark(#fff,#2c3338);--fp-border:light-dark(#dcdcde,#1d2327);--fp-text:light-dark(#1d2327,#f0f0f1);--fp-muted:light-dark(#646970,#a7aaad);--fp-accent:light-dark(#2271b1,#72aee6);--fp-user-bg:light-dark(#e8f4ff,#22384f);--fp-assistant-bg:light-dark(#f4f4f4,#263743);--fp-system-bg:light-dark(#fff6e5,#3a2f1c);--fp-tool-bg:light-dark(#e5eaff,#1f2f3d);--fp-tool-border:light-dark(#6c63ff,#9bb0ff);--fp-input-bg:light-dark(#fff,#2b3238);--fp-danger:light-dark(#d63638,#ff8a8a)}.clawpress-panel{background:var(--fp-bg);border-right:1px solid var(--fp-border);border-left:1px solid var(--fp-border);box-shadow:2px 0 5px rgba(0,0,0,.05);box-sizing:border-box;color:var(--fp-text);display:flex;flex-direction:column;height:calc(100vh - var(--wp-admin--admin-bar--height, 0px));opacity:0;padding-right:6px;padding-left:6px;pointer-events:none;position:fixed;left:0;top:var(--wp-admin--admin-bar--height,0);transform:translateX(-100%);transition:transform .25s cubic-bezier(.4,0,.2,1),opacity .18s ease,width .2s ease;width:400px;will-change:transform,opacity;z-index:1000}.clawpress-panel:after{background:var(--fp-surface);bottom:0;content:"";pointer-events:none;position:absolute;left:0;top:0;width:6px;z-index:0}.clawpress-panel>*{position:relative;z-index:1}.clawpress-panel[data-theme=light]{color-scheme:light}.clawpress-panel[data-theme=dark]{color-scheme:dark}.clawpress-panel[data-theme=light] syntax-highlight{color-scheme:light}.clawpress-panel[data-theme=dark] syntax-highlight{color-scheme:dark}body.clawpress-panel-open .clawpress-panel{opacity:1;pointer-events:auto;transform:translateX(0)}body.clawpress-panel-open #wpcontent{margin-left:var(--clawpress-panel-width,400px);transition:margin-left .25s cubic-bezier(.4,0,.2,1)}.clawpress-mock-panel{background:var(--fp-surface,#fff);border:1px solid var(--fp-border,#ddd);border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,.08);color:var(--fp-text,#1d2327);display:flex;flex-direction:column;gap:6px;padding:8px;position:fixed;left:calc(var(--clawpress-panel-width, 400px) + 12px);top:calc(var(--wp-admin--admin-bar--height, 0px) + 10px);z-index:1001}.clawpress-mock-panel[data-theme=light]{color-scheme:light}.clawpress-mock-panel[data-theme=dark]{color-scheme:dark}.clawpress-mock-panel[data-theme=light] syntax-highlight{color-scheme:light}.clawpress-mock-panel[data-theme=dark] syntax-highlight{color-scheme:dark}.clawpress-mock-badge{align-self:flex-start;background:var(--fp-text,#1d2327);border-radius:10px;color:var(--fp-bg,#fff);font-size:11px;letter-spacing:.04em;padding:2px 6px;text-transform:uppercase}.clawpress-mock-buttons{display:flex;flex-wrap:wrap;gap:4px}.clawpress-mock-fieldset{border:1px solid var(--fp-border,#ddd);border-radius:6px;margin:0;min-width:0;padding:6px}.clawpress-mock-fieldset legend{font-size:11px;font-weight:600;padding:0 4px}.clawpress-drag-handle{background:var(--fp-surface);bottom:0;cursor:ew-resize;right:0;position:absolute;top:0;width:6px;z-index:0}@media(prefers-reduced-motion:reduce){.clawpress-panel,body.clawpress-panel-open #wpcontent{transition:none!important}}.clawpress-toggle{bottom:20px;position:fixed;left:20px;transition:opacity .2s ease;z-index:999}body.clawpress-panel-open .clawpress-toggle{display:none}.clawpress-header{align-items:center;background:var(--fp-surface);display:flex;gap:8px;justify-content:flex-start;padding:10px}.clawpress-header-meta{display:flex;flex-direction:column;gap:2px}.clawpress-title{color:var(--fp-text);font-size:13px;font-weight:400}.clawpress-status{align-items:center;color:var(--fp-muted);display:inline-flex;font-size:11px;gap:6px}.clawpress-status-dot{background:#d63638;border-radius:999px;box-shadow:0 0 0 1px rgba(0,0,0,.08);height:7px;width:7px}.clawpress-status-online .clawpress-status-dot{background:#1a7f37}.clawpress-status-offline .clawpress-status-dot{background:#d63638}.clawpress-status-label{opacity:.9}.clawpress-theme-toggle{align-items:center;background:transparent;border:none;border-radius:999px;color:var(--fp-text);cursor:pointer;display:inline-flex;height:32px;justify-content:center;margin-right:auto;margin-left:6px;padding:4px;transition:color .15s ease;width:32px}.clawpress-theme-toggle svg{display:block;fill:currentColor;height:24px;stroke:currentColor;stroke-width:0;width:24px}.clawpress-close{align-items:center;background:transparent;border:none;border-radius:999px;color:var(--fp-muted);cursor:pointer;display:inline-flex;height:32px;justify-content:center;transition:color .15s ease;width:32px}.clawpress-close:hover,.clawpress-theme-toggle:hover{color:#72aee6}.clawpress-close svg{display:block;fill:currentColor;height:24px;stroke:currentColor;stroke-width:0;width:24px}.clawpress-messages{background:var(--fp-surface-contrast);border:1px solid var(--fp-border);border-radius:3px;display:flex;flex:1;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;gap:10px;overflow-y:auto;padding:14px 12px}.clawpress-msg-content{border-radius:3px;margin:0;padding:8px 10px;white-space:pre-wrap;word-wrap:break-word;color:var(--fp-text)}.clawpress-card,.clawpress-msg-content{background:var(--fp-bg);border:1px solid var(--fp-border)}.clawpress-card{border-radius:6px;overflow:hidden}.clawpress-card.clawpress-card-error{background:#fff4f4;border:1px solid #d63638}.clawpress-card.clawpress-card-error .clawpress-card-title{color:#8a1f1f}.clawpress-card.clawpress-card-error .clawpress-card-subtitle{color:#a13939}.clawpress-card.clawpress-card-error .clawpress-card-text{color:#7f1d1d}.clawpress-card-emoji{font-size:20px;line-height:1}.clawpress-card-body{min-width:0;width:100%}.clawpress-card-section-title{padding:12px 14px 8px}.clawpress-card-section-subtitle{padding:0 14px 10px}.clawpress-card-section-buttons,.clawpress-card-section-content{padding:0 14px 14px}.clawpress-card-title{color:var(--fp-text);font-weight:600;margin:0}.clawpress-card-subtitle{color:var(--fp-muted);font-size:11px;letter-spacing:.04em;margin:0;text-transform:uppercase}.clawpress-card-text{color:var(--fp-text);margin:0;white-space:pre-line}.clawpress-card-welcome-title-line{align-items:center;display:flex;gap:8px}.clawpress-card-actions{display:flex;flex-wrap:wrap;gap:6px;margin:0}.clawpress-msg-label{color:var(--fp-muted);font-size:10px;letter-spacing:.04em;margin-bottom:4px;text-transform:uppercase}.clawpress-msg.clawpress-user .clawpress-msg-content{background:var(--fp-user-bg)}.clawpress-msg.clawpress-user{align-self:flex-end;max-width:85%}.clawpress-msg.clawpress-assistant .clawpress-msg-content{background:var(--fp-assistant-bg);border:1px solid var(--fp-border)}.clawpress-msg.clawpress-assistant{align-self:flex-start;max-width:85%}.clawpress-msg.clawpress-system{align-self:stretch}.clawpress-msg.clawpress-system .clawpress-msg-content{background:var(--fp-system-bg);border:1px dashed var(--fp-border);font-family:Courier New,Courier,monospace;font-style:normal;opacity:1}.clawpress-msg.clawpress-tool_planning .clawpress-msg-content{background:transparent;border:none;font-style:italic;opacity:.85;padding-right:0;padding-left:0}.clawpress-msg-content.clawpress-thinking:after{animation:clawpress-thinking-dots 1.2s steps(3) infinite;clip-path:inset(0 100% 0 0);content:"...";display:inline-block;line-height:1;margin-right:2px;overflow:hidden;vertical-align:baseline;white-space:nowrap;width:1.2em}@keyframes clawpress-thinking-dots{0%{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 0 0 0)}}.clawpress-input{background:var(--fp-surface);display:flex;flex-direction:column;gap:6px;padding:6px 0}.clawpress-input textarea{background:var(--fp-input-bg);border:1px solid var(--fp-border);color:var(--fp-text);font-size:14px;height:60px;padding:5px;resize:none;width:100%}.clawpress-input-footer{align-items:center;display:flex;justify-content:space-between;min-height:28px}.clawpress-context-slot{align-items:center;display:flex;min-height:22px}.clawpress-context-indicator{align-items:center;border-radius:999px;color:var(--fp-muted);cursor:default;display:inline-flex;height:18px;justify-content:center;outline:none;position:relative;width:18px}.clawpress-context-pie{--clawpress-context-used:0%;background:conic-gradient(var(--fp-accent) var(--clawpress-context-used),var(--fp-surface-contrast) 0);border:1px solid var(--fp-border);border-radius:50%;display:inline-block;height:14px;position:relative;width:14px}.clawpress-context-pie:after{background:var(--fp-surface);border-radius:50%;content:"";height:6px;right:3px;position:absolute;top:3px;width:6px}.clawpress-context-tooltip{background:rgba(30,30,32,.95);border:1px solid hsla(0,0%,100%,.14);border-radius:12px;bottom:calc(100% + 8px);box-shadow:0 10px 24px rgba(0,0,0,.32);color:#fff;right:0;opacity:0;padding:12px 14px;pointer-events:none;position:absolute;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease;width:220px;z-index:3}.clawpress-context-indicator:focus-visible .clawpress-context-tooltip,.clawpress-context-indicator:hover .clawpress-context-tooltip{opacity:1;transform:translateY(0)}.clawpress-context-tooltip-title{color:hsla(0,0%,100%,.84);font-size:13px;font-weight:600;margin-bottom:4px}.clawpress-context-tooltip-line{color:#fff;font-size:12px;font-weight:600;line-height:1.45}.clawpress-context-tooltip-note{color:hsla(0,0%,100%,.88);font-size:11px;font-weight:500;line-height:1.35;margin-top:8px}.clawpress-suggestions{display:flex;flex-direction:column;gap:4px}.clawpress-suggestions-label{color:var(--fp-muted);font-size:11px}.clawpress-suggestions-list{display:flex;flex-wrap:wrap;gap:4px}.clawpress-suggestion{font-size:11px;line-height:1.5;padding:2px 6px}.clawpress-tool-dialog{background:var(--fp-surface);border:1px solid var(--fp-border);border-radius:4px;color:var(--fp-text);margin:0;position:relative}.clawpress-tool-dialog-summary{align-items:center;display:flex;gap:8px;justify-content:space-between;padding:10px}.clawpress-tool-dialog-heading{align-items:center;display:flex;flex:1 1 auto;gap:8px;min-width:0}.clawpress-tool-dialog-title{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.clawpress-tool-dialog-status{background:var(--fp-muted);border:1px solid var(--fp-border);border-radius:50%;flex:0 0 10px;height:10px;width:10px}.clawpress-tool-dialog-status-idle{background:var(--fp-muted)}.clawpress-tool-dialog-status-running{background:var(--fp-accent)}.clawpress-tool-dialog-status-blocked{background:light-dark(#dba617,#f0c33c)}.clawpress-tool-dialog-status-error{background:var(--fp-danger)}.clawpress-tool-dialog-status-done{background:light-dark(#00a32a,#00d084)}.clawpress-tool-dialog-status-cancelled{background:light-dark(#8c8f94,#9ea3a8)}.clawpress-tool-dialog-actions{align-items:center;display:inline-flex;flex:0 0 auto;gap:6px}.clawpress-tool-dialog-error-icon{align-items:center;color:var(--fp-danger);display:inline-flex;height:18px;justify-content:center;width:18px}.clawpress-tool-dialog-error-icon svg{fill:currentColor;height:18px;width:18px}.clawpress-tool-dialog-body{background:var(--fp-surface-contrast);border-bottom-right-radius:4px;border-bottom-left-radius:4px;border-top:1px solid var(--fp-border);padding:10px}.clawpress-tool-dialog-body p{font-size:13px;line-height:1.5;margin:8px 0}.clawpress-tool-dialog-form{display:flex;flex-direction:column;gap:10px}.clawpress-tool-dialog-field{display:flex;flex-direction:column;gap:4px}.clawpress-tool-dialog-label{font-size:12px;font-weight:600}.clawpress-tool-dialog-input{font-size:13px}.clawpress-tool-dialog-help{color:var(--fp-muted);font-size:12px}.clawpress-tool-dialog-form-actions{display:flex;gap:6px}.clawpress-tool-result{display:flex;flex-direction:column;gap:10px}.clawpress-tool-result-summary{color:var(--fp-muted);display:flex;flex-wrap:wrap;font-size:12px;gap:12px}.clawpress-tool-result-list{background:var(--fp-bg);border:1px solid var(--fp-border);border-radius:4px;padding:6px 8px}.clawpress-tool-result-list summary{cursor:pointer;font-weight:600;margin-bottom:6px}.clawpress-tool-result-list ul{display:flex;flex-direction:column;gap:6px;list-style:none;margin:0;padding:0}.clawpress-tool-result-list li{display:flex;flex-wrap:wrap;font-size:13px;gap:8px}.clawpress-tool-result-title{font-weight:600}.clawpress-tool-result-meta{color:var(--fp-muted)}.clawpress-tool-result-note{color:var(--fp-muted);font-style:italic;margin:0}.clawpress-tool-result-actions{display:flex;gap:6px}.clawpress-tool-dialog syntax-highlight{background:transparent;display:block}.clawpress-tool-dialog syntax-highlight,.clawpress-tool-dialog-code{border:1px solid var(--fp-border);border-radius:4px;overflow-x:auto;padding:8px}.clawpress-tool-dialog-code{background:var(--fp-bg);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;white-space:pre-wrap}.clawpress-tool-dialog-diff{margin-top:8px}.clawpress-card-setup{width:100%}.clawpress-card-setup-title-line{align-items:center;display:flex;gap:6px}.clawpress-card-setup-emoji{font-size:16px;line-height:1}.clawpress-card-setup-step-line{margin:0}.clawpress-card-setup-progress{align-items:center;display:flex;margin-top:6px;max-width:100%;width:320px}.clawpress-card-setup-progress-item{align-items:center;display:flex;flex:1 1 auto;min-width:0}.clawpress-card-setup-progress-dot{align-items:center;background:var(--fp-bg);border:2px solid var(--fp-border);border-radius:999px;box-sizing:border-box;color:var(--fp-muted);display:flex;flex:0 0 auto;font-size:10px;font-weight:600;height:18px;justify-content:center;line-height:1;width:18px}.clawpress-card-setup-progress-line{background:var(--fp-border);flex:1 1 auto;height:2px}.clawpress-card-setup-progress-completed .clawpress-card-setup-progress-dot,.clawpress-card-setup-progress-done .clawpress-card-setup-progress-dot{background:var(--fp-accent);border-color:var(--fp-accent);color:#fff}.clawpress-card-setup-progress-completed .clawpress-card-setup-progress-line,.clawpress-card-setup-progress-done .clawpress-card-setup-progress-line{background:var(--fp-accent)}.clawpress-card-setup-progress-current .clawpress-card-setup-progress-dot{background:var(--fp-bg);border-color:var(--fp-accent);color:var(--fp-accent)}.clawpress-card-setup-field-list{display:flex;flex-direction:column;gap:6px;margin-top:8px}.clawpress-card-setup-field-row{align-items:flex-start;display:flex;gap:8px}.clawpress-card-setup-field-label{color:var(--fp-muted);font-size:12px;line-height:1.4;min-width:52px;padding-top:8px}.clawpress-card-setup-field-value{background:var(--fp-surface);border:1px solid var(--fp-border);border-radius:4px;color:var(--fp-text);font-family:Courier New,Courier,monospace;font-size:12px;margin:0;padding:4px 8px;white-space:pre-wrap;word-break:break-word}.clawpress-card-setup-detail{color:var(--fp-muted);font-size:12px;margin-top:8px;white-space:pre-line}.clawpress-card-setup-error{color:var(--fp-danger);font-size:12px;margin-top:8px;white-space:pre-line}.clawpress-card-setup-buttons{display:flex;flex-direction:column;gap:8px}.clawpress-card-setup-inline-controls{align-items:center;display:flex;gap:6px}.clawpress-card-setup-select{max-width:100%;min-width:180px}.clawpress-card-setup-input{max-width:100%;min-width:220px}.clawpress-card-user-confirmation{background:#fff8e5;border:1px solid #dba617}.clawpress-card-user-confirmation .clawpress-card-title{color:#8a5b00}.clawpress-card-user-confirmation .clawpress-card-subtitle{color:#9a6c10}.clawpress-card-user-confirmation .clawpress-card-text{color:#6d4d00} +.clawpress-mock-panel,.clawpress-panel{--fp-bg:light-dark(#fff,#1d2327);--fp-surface:light-dark(#f6f7f7,#1d2327);--fp-surface-contrast:light-dark(#fff,#2c3338);--fp-border:light-dark(#dcdcde,#1d2327);--fp-text:light-dark(#1d2327,#f0f0f1);--fp-muted:light-dark(#646970,#a7aaad);--fp-accent:light-dark(#2271b1,#72aee6);--fp-user-bg:light-dark(#e8f4ff,#22384f);--fp-assistant-bg:light-dark(#f4f4f4,#263743);--fp-system-bg:light-dark(#fff6e5,#3a2f1c);--fp-tool-bg:light-dark(#e5eaff,#1f2f3d);--fp-tool-border:light-dark(#6c63ff,#9bb0ff);--fp-input-bg:light-dark(#fff,#2b3238);--fp-danger:light-dark(#d63638,#ff8a8a)}.clawpress-panel{background:var(--fp-bg);border-right:1px solid var(--fp-border);border-left:1px solid var(--fp-border);box-shadow:2px 0 5px rgba(0,0,0,.05);box-sizing:border-box;color:var(--fp-text);display:flex;flex-direction:column;height:calc(100vh - var(--wp-admin--admin-bar--height, 0px));opacity:0;padding-right:6px;padding-left:6px;pointer-events:none;position:fixed;left:0;top:var(--wp-admin--admin-bar--height,0);transform:translateX(-100%);transition:transform .25s cubic-bezier(.4,0,.2,1),opacity .18s ease,width .2s ease;width:400px;will-change:transform,opacity;z-index:1000}.clawpress-panel:after{background:var(--fp-surface);bottom:0;content:"";pointer-events:none;position:absolute;left:0;top:0;width:6px;z-index:0}.clawpress-panel>*{position:relative;z-index:1}.clawpress-panel[data-theme=light]{color-scheme:light}.clawpress-panel[data-theme=dark]{color-scheme:dark}.clawpress-panel[data-theme=light] syntax-highlight{color-scheme:light}.clawpress-panel[data-theme=dark] syntax-highlight{color-scheme:dark}body.clawpress-panel-open .clawpress-panel{opacity:1;pointer-events:auto;transform:translateX(0)}body.clawpress-panel-open #wpcontent{margin-left:var(--clawpress-panel-width,400px);transition:margin-left .25s cubic-bezier(.4,0,.2,1)}.clawpress-mock-panel{background:var(--fp-surface,#fff);border:1px solid var(--fp-border,#ddd);border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,.08);color:var(--fp-text,#1d2327);display:flex;flex-direction:column;gap:6px;padding:8px;position:fixed;left:calc(var(--clawpress-panel-width, 400px) + 12px);top:calc(var(--wp-admin--admin-bar--height, 0px) + 10px);z-index:1001}.clawpress-mock-panel[data-theme=light]{color-scheme:light}.clawpress-mock-panel[data-theme=dark]{color-scheme:dark}.clawpress-mock-panel[data-theme=light] syntax-highlight{color-scheme:light}.clawpress-mock-panel[data-theme=dark] syntax-highlight{color-scheme:dark}.clawpress-mock-badge{align-self:flex-start;background:var(--fp-text,#1d2327);border-radius:10px;color:var(--fp-bg,#fff);font-size:11px;letter-spacing:.04em;padding:2px 6px;text-transform:uppercase}.clawpress-mock-buttons{display:flex;flex-wrap:wrap;gap:4px}.clawpress-mock-fieldset{border:1px solid var(--fp-border,#ddd);border-radius:6px;margin:0;min-width:0;padding:6px}.clawpress-mock-fieldset legend{font-size:11px;font-weight:600;padding:0 4px}.clawpress-drag-handle{background:var(--fp-surface);bottom:0;cursor:ew-resize;right:0;position:absolute;top:0;width:6px;z-index:0}@media(prefers-reduced-motion:reduce){.clawpress-panel,body.clawpress-panel-open #wpcontent{transition:none!important}}.clawpress-toggle{bottom:20px;position:fixed;left:20px;transition:opacity .2s ease;z-index:999}body.clawpress-panel-open .clawpress-toggle{display:none}.clawpress-header{align-items:center;background:var(--fp-surface);display:flex;gap:8px;justify-content:flex-start;padding:10px}.clawpress-header-meta{display:flex;flex-direction:column;gap:2px}.clawpress-title{color:var(--fp-text);font-size:13px;font-weight:400}.clawpress-status{align-items:center;color:var(--fp-muted);display:inline-flex;font-size:11px;gap:6px}.clawpress-status-dot{background:#d63638;border-radius:999px;box-shadow:0 0 0 1px rgba(0,0,0,.08);height:7px;width:7px}.clawpress-status-online .clawpress-status-dot{background:#1a7f37}.clawpress-status-offline .clawpress-status-dot{background:#d63638}.clawpress-status-label{opacity:.9}.clawpress-theme-toggle{align-items:center;background:transparent;border:none;border-radius:999px;color:var(--fp-text);cursor:pointer;display:inline-flex;height:32px;justify-content:center;margin-right:auto;margin-left:6px;padding:4px;transition:color .15s ease;width:32px}.clawpress-theme-toggle svg{display:block;fill:currentColor;height:24px;stroke:currentColor;stroke-width:0;width:24px}.clawpress-close{align-items:center;background:transparent;border:none;border-radius:999px;color:var(--fp-muted);cursor:pointer;display:inline-flex;height:32px;justify-content:center;transition:color .15s ease;width:32px}.clawpress-close:hover,.clawpress-theme-toggle:hover{color:#72aee6}.clawpress-close svg{display:block;fill:currentColor;height:24px;stroke:currentColor;stroke-width:0;width:24px}.clawpress-messages{background:var(--fp-surface-contrast);border:1px solid var(--fp-border);border-radius:3px;display:flex;flex:1;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;gap:10px;overflow-y:auto;padding:14px 12px}.clawpress-msg-content{border-radius:3px;margin:0;padding:8px 10px;white-space:pre-wrap;word-wrap:break-word;color:var(--fp-text)}.clawpress-card,.clawpress-msg-content{background:var(--fp-bg);border:1px solid var(--fp-border)}.clawpress-card{border-radius:6px;overflow:hidden}.clawpress-card.clawpress-card-error{background:#fff4f4;border:1px solid #d63638}.clawpress-card.clawpress-card-error .clawpress-card-title{color:#8a1f1f}.clawpress-card.clawpress-card-error .clawpress-card-subtitle{color:#a13939}.clawpress-card.clawpress-card-error .clawpress-card-text{color:#7f1d1d}.clawpress-card-emoji{font-size:20px;line-height:1}.clawpress-card-body{min-width:0;width:100%}.clawpress-card-section-title{padding:12px 14px 8px}.clawpress-card-section-subtitle{padding:0 14px 10px}.clawpress-card-section-buttons,.clawpress-card-section-content{padding:0 14px 14px}.clawpress-card-title{color:var(--fp-text);font-weight:600;margin:0}.clawpress-card-subtitle{color:var(--fp-muted);font-size:11px;letter-spacing:.04em;margin:0;text-transform:uppercase}.clawpress-card-text{color:var(--fp-text);margin:0;white-space:pre-line}.clawpress-card-welcome-title-line{align-items:center;display:flex;gap:8px}.clawpress-card-actions{display:flex;flex-wrap:wrap;gap:6px;margin:0}.clawpress-msg-label{color:var(--fp-muted);font-size:10px;letter-spacing:.04em;margin-bottom:4px;text-transform:uppercase}.clawpress-msg.clawpress-user .clawpress-msg-content{background:var(--fp-user-bg)}.clawpress-msg.clawpress-user{align-self:flex-end;max-width:85%}.clawpress-msg.clawpress-assistant .clawpress-msg-content{background:var(--fp-assistant-bg);border:1px solid var(--fp-border)}.clawpress-msg.clawpress-assistant{align-self:flex-start;max-width:85%}.clawpress-msg.clawpress-system{align-self:stretch}.clawpress-msg.clawpress-system .clawpress-msg-content{background:var(--fp-system-bg);border:1px dashed var(--fp-border);font-family:Courier New,Courier,monospace;font-style:normal;opacity:1}.clawpress-msg.clawpress-tool_planning .clawpress-msg-content{background:transparent;border:none;font-style:italic;opacity:.85;padding-right:0;padding-left:0}.clawpress-msg-content.clawpress-thinking:after{animation:clawpress-thinking-dots 1.2s step-end infinite;content:"...";display:inline-block;line-height:1;margin-right:2px;overflow:hidden;vertical-align:baseline;white-space:nowrap;width:1ch}@keyframes clawpress-thinking-dots{0%,24.99%{width:1ch}25%,49.99%{width:2ch}50%,74.99%{width:3ch}75%,to{width:1ch}}.clawpress-input{background:var(--fp-surface);display:flex;flex-direction:column;gap:6px;padding:6px 0}.clawpress-input textarea{background:var(--fp-input-bg);border:1px solid var(--fp-border);color:var(--fp-text);font-size:14px;height:60px;padding:5px;resize:none;width:100%}.clawpress-input-footer{align-items:center;display:flex;justify-content:space-between;min-height:28px}.clawpress-context-slot{align-items:center;display:flex;min-height:22px}.clawpress-context-indicator{align-items:center;border-radius:999px;color:var(--fp-muted);cursor:default;display:inline-flex;height:18px;justify-content:center;outline:none;position:relative;width:18px}.clawpress-context-pie{--clawpress-context-used:0%;background:conic-gradient(var(--fp-accent) var(--clawpress-context-used),var(--fp-surface-contrast) 0);border:1px solid var(--fp-border);border-radius:50%;display:inline-block;height:14px;position:relative;width:14px}.clawpress-context-pie:after{background:var(--fp-surface);border-radius:50%;content:"";height:6px;right:3px;position:absolute;top:3px;width:6px}.clawpress-context-tooltip{background:rgba(30,30,32,.95);border:1px solid hsla(0,0%,100%,.14);border-radius:12px;bottom:calc(100% + 8px);box-shadow:0 10px 24px rgba(0,0,0,.32);color:#fff;right:0;opacity:0;padding:12px 14px;pointer-events:none;position:absolute;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease;width:220px;z-index:3}.clawpress-context-indicator:focus-visible .clawpress-context-tooltip,.clawpress-context-indicator:hover .clawpress-context-tooltip{opacity:1;transform:translateY(0)}.clawpress-context-tooltip-title{color:hsla(0,0%,100%,.84);font-size:13px;font-weight:600;margin-bottom:4px}.clawpress-context-tooltip-line{color:#fff;font-size:12px;font-weight:600;line-height:1.45}.clawpress-context-tooltip-note{color:hsla(0,0%,100%,.88);font-size:11px;font-weight:500;line-height:1.35;margin-top:8px}.clawpress-suggestions{display:flex;flex-direction:column;gap:4px}.clawpress-suggestions-label{color:var(--fp-muted);font-size:11px}.clawpress-suggestions-list{display:flex;flex-wrap:wrap;gap:4px}.clawpress-suggestion{font-size:11px;line-height:1.5;padding:2px 6px}.clawpress-tool-dialog{background:var(--fp-surface);border:1px solid var(--fp-border);border-radius:4px;color:var(--fp-text);margin:0;position:relative}.clawpress-tool-dialog-summary{align-items:center;display:flex;gap:8px;justify-content:space-between;padding:10px}.clawpress-tool-dialog-heading{align-items:center;display:flex;flex:1 1 auto;gap:8px;min-width:0}.clawpress-tool-dialog-title{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.clawpress-tool-dialog-status{background:var(--fp-muted);border:1px solid var(--fp-border);border-radius:50%;flex:0 0 10px;height:10px;width:10px}.clawpress-tool-dialog-status-idle{background:var(--fp-muted)}.clawpress-tool-dialog-status-running{background:var(--fp-accent)}.clawpress-tool-dialog-status-blocked{background:light-dark(#dba617,#f0c33c)}.clawpress-tool-dialog-status-error{background:var(--fp-danger)}.clawpress-tool-dialog-status-done{background:light-dark(#00a32a,#00d084)}.clawpress-tool-dialog-status-cancelled{background:light-dark(#8c8f94,#9ea3a8)}.clawpress-tool-dialog-actions{align-items:center;display:inline-flex;flex:0 0 auto;gap:6px}.clawpress-tool-dialog-error-icon{align-items:center;color:var(--fp-danger);display:inline-flex;height:18px;justify-content:center;width:18px}.clawpress-tool-dialog-error-icon svg{fill:currentColor;height:18px;width:18px}.clawpress-tool-dialog-body{background:var(--fp-surface-contrast);border-bottom-right-radius:4px;border-bottom-left-radius:4px;border-top:1px solid var(--fp-border);padding:10px}.clawpress-tool-dialog-body p{font-size:13px;line-height:1.5;margin:8px 0}.clawpress-tool-dialog-form{display:flex;flex-direction:column;gap:10px}.clawpress-tool-dialog-field{display:flex;flex-direction:column;gap:4px}.clawpress-tool-dialog-label{font-size:12px;font-weight:600}.clawpress-tool-dialog-input{font-size:13px}.clawpress-tool-dialog-help{color:var(--fp-muted);font-size:12px}.clawpress-tool-dialog-form-actions{display:flex;gap:6px}.clawpress-tool-result{display:flex;flex-direction:column;gap:10px}.clawpress-tool-result-summary{color:var(--fp-muted);display:flex;flex-wrap:wrap;font-size:12px;gap:12px}.clawpress-tool-result-list{background:var(--fp-bg);border:1px solid var(--fp-border);border-radius:4px;padding:6px 8px}.clawpress-tool-result-list summary{cursor:pointer;font-weight:600;margin-bottom:6px}.clawpress-tool-result-list ul{display:flex;flex-direction:column;gap:6px;list-style:none;margin:0;padding:0}.clawpress-tool-result-list li{display:flex;flex-wrap:wrap;font-size:13px;gap:8px}.clawpress-tool-result-title{font-weight:600}.clawpress-tool-result-meta{color:var(--fp-muted)}.clawpress-tool-result-note{color:var(--fp-muted);font-style:italic;margin:0}.clawpress-tool-result-actions{display:flex;gap:6px}.clawpress-tool-dialog syntax-highlight{background:transparent;display:block}.clawpress-tool-dialog syntax-highlight,.clawpress-tool-dialog-code{border:1px solid var(--fp-border);border-radius:4px;overflow-x:auto;padding:8px}.clawpress-tool-dialog-code{background:var(--fp-bg);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;white-space:pre-wrap}.clawpress-tool-dialog-diff{margin-top:8px}.clawpress-card-setup{width:100%}.clawpress-card-setup-title-line{align-items:center;display:flex;gap:6px}.clawpress-card-setup-emoji{font-size:16px;line-height:1}.clawpress-card-setup-step-line{margin:0}.clawpress-card-setup-progress{align-items:center;display:flex;margin-top:6px;max-width:100%;width:320px}.clawpress-card-setup-progress-item{align-items:center;display:flex;flex:1 1 auto;min-width:0}.clawpress-card-setup-progress-dot{align-items:center;background:var(--fp-bg);border:2px solid var(--fp-border);border-radius:999px;box-sizing:border-box;color:var(--fp-muted);display:flex;flex:0 0 auto;font-size:10px;font-weight:600;height:18px;justify-content:center;line-height:1;width:18px}.clawpress-card-setup-progress-line{background:var(--fp-border);flex:1 1 auto;height:2px}.clawpress-card-setup-progress-completed .clawpress-card-setup-progress-dot,.clawpress-card-setup-progress-done .clawpress-card-setup-progress-dot{background:var(--fp-accent);border-color:var(--fp-accent);color:#fff}.clawpress-card-setup-progress-completed .clawpress-card-setup-progress-line,.clawpress-card-setup-progress-done .clawpress-card-setup-progress-line{background:var(--fp-accent)}.clawpress-card-setup-progress-current .clawpress-card-setup-progress-dot{background:var(--fp-bg);border-color:var(--fp-accent);color:var(--fp-accent)}.clawpress-card-setup-field-list{display:flex;flex-direction:column;gap:6px;margin-top:8px}.clawpress-card-setup-field-row{align-items:flex-start;display:flex;gap:8px}.clawpress-card-setup-field-label{color:var(--fp-muted);font-size:12px;line-height:1.4;min-width:52px;padding-top:8px}.clawpress-card-setup-field-value{background:var(--fp-surface);border:1px solid var(--fp-border);border-radius:4px;color:var(--fp-text);font-family:Courier New,Courier,monospace;font-size:12px;margin:0;padding:4px 8px;white-space:pre-wrap;word-break:break-word}.clawpress-card-setup-detail{color:var(--fp-muted);font-size:12px;margin-top:8px;white-space:pre-line}.clawpress-card-setup-error{color:var(--fp-danger);font-size:12px;margin-top:8px;white-space:pre-line}.clawpress-card-setup-buttons{display:flex;flex-direction:column;gap:8px}.clawpress-card-setup-inline-controls{align-items:center;display:flex;gap:6px}.clawpress-card-setup-select{max-width:100%;min-width:180px}.clawpress-card-setup-input{max-width:100%;min-width:220px}.clawpress-card-user-confirmation{background:#fff8e5;border:1px solid #dba617}.clawpress-card-user-confirmation .clawpress-card-title{color:#8a5b00}.clawpress-card-user-confirmation .clawpress-card-subtitle{color:#9a6c10}.clawpress-card-user-confirmation .clawpress-card-text{color:#6d4d00} diff --git a/build/panel/panel.asset.php b/build/panel/panel.asset.php index 4ef8de5..901369d 100644 --- a/build/panel/panel.asset.php +++ b/build/panel/panel.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-element', 'wp-i18n'), 'version' => '80b5e6a1d154a9389ca4'); + array('react-jsx-runtime', 'wp-element', 'wp-i18n'), 'version' => 'e8334107ad65c63f6222'); diff --git a/build/panel/panel.css b/build/panel/panel.css index 95dc270..b08bc3d 100644 --- a/build/panel/panel.css +++ b/build/panel/panel.css @@ -1 +1 @@ -.clawpress-mock-panel,.clawpress-panel{--fp-bg:light-dark(#fff,#1d2327);--fp-surface:light-dark(#f6f7f7,#1d2327);--fp-surface-contrast:light-dark(#fff,#2c3338);--fp-border:light-dark(#dcdcde,#1d2327);--fp-text:light-dark(#1d2327,#f0f0f1);--fp-muted:light-dark(#646970,#a7aaad);--fp-accent:light-dark(#2271b1,#72aee6);--fp-user-bg:light-dark(#e8f4ff,#22384f);--fp-assistant-bg:light-dark(#f4f4f4,#263743);--fp-system-bg:light-dark(#fff6e5,#3a2f1c);--fp-tool-bg:light-dark(#e5eaff,#1f2f3d);--fp-tool-border:light-dark(#6c63ff,#9bb0ff);--fp-input-bg:light-dark(#fff,#2b3238);--fp-danger:light-dark(#d63638,#ff8a8a)}.clawpress-panel{background:var(--fp-bg);border-left:1px solid var(--fp-border);border-right:1px solid var(--fp-border);box-shadow:-2px 0 5px rgba(0,0,0,.05);box-sizing:border-box;color:var(--fp-text);display:flex;flex-direction:column;height:calc(100vh - var(--wp-admin--admin-bar--height, 0px));opacity:0;padding-left:6px;padding-right:6px;pointer-events:none;position:fixed;right:0;top:var(--wp-admin--admin-bar--height,0);transform:translateX(100%);transition:transform .25s cubic-bezier(.4,0,.2,1),opacity .18s ease,width .2s ease;width:400px;will-change:transform,opacity;z-index:1000}.clawpress-panel:after{background:var(--fp-surface);bottom:0;content:"";pointer-events:none;position:absolute;right:0;top:0;width:6px;z-index:0}.clawpress-panel>*{position:relative;z-index:1}.clawpress-panel[data-theme=light]{color-scheme:light}.clawpress-panel[data-theme=dark]{color-scheme:dark}.clawpress-panel[data-theme=light] syntax-highlight{color-scheme:light}.clawpress-panel[data-theme=dark] syntax-highlight{color-scheme:dark}body.clawpress-panel-open .clawpress-panel{opacity:1;pointer-events:auto;transform:translateX(0)}body.clawpress-panel-open #wpcontent{margin-right:var(--clawpress-panel-width,400px);transition:margin-right .25s cubic-bezier(.4,0,.2,1)}.clawpress-mock-panel{background:var(--fp-surface,#fff);border:1px solid var(--fp-border,#ddd);border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,.08);color:var(--fp-text,#1d2327);display:flex;flex-direction:column;gap:6px;padding:8px;position:fixed;right:calc(var(--clawpress-panel-width, 400px) + 12px);top:calc(var(--wp-admin--admin-bar--height, 0px) + 10px);z-index:1001}.clawpress-mock-panel[data-theme=light]{color-scheme:light}.clawpress-mock-panel[data-theme=dark]{color-scheme:dark}.clawpress-mock-panel[data-theme=light] syntax-highlight{color-scheme:light}.clawpress-mock-panel[data-theme=dark] syntax-highlight{color-scheme:dark}.clawpress-mock-badge{align-self:flex-start;background:var(--fp-text,#1d2327);border-radius:10px;color:var(--fp-bg,#fff);font-size:11px;letter-spacing:.04em;padding:2px 6px;text-transform:uppercase}.clawpress-mock-buttons{display:flex;flex-wrap:wrap;gap:4px}.clawpress-mock-fieldset{border:1px solid var(--fp-border,#ddd);border-radius:6px;margin:0;min-width:0;padding:6px}.clawpress-mock-fieldset legend{font-size:11px;font-weight:600;padding:0 4px}.clawpress-drag-handle{background:var(--fp-surface);bottom:0;cursor:ew-resize;left:0;position:absolute;top:0;width:6px;z-index:0}@media(prefers-reduced-motion:reduce){.clawpress-panel,body.clawpress-panel-open #wpcontent{transition:none!important}}.clawpress-toggle{bottom:20px;position:fixed;right:20px;transition:opacity .2s ease;z-index:999}body.clawpress-panel-open .clawpress-toggle{display:none}.clawpress-header{align-items:center;background:var(--fp-surface);display:flex;gap:8px;justify-content:flex-start;padding:10px}.clawpress-header-meta{display:flex;flex-direction:column;gap:2px}.clawpress-title{color:var(--fp-text);font-size:13px;font-weight:400}.clawpress-status{align-items:center;color:var(--fp-muted);display:inline-flex;font-size:11px;gap:6px}.clawpress-status-dot{background:#d63638;border-radius:999px;box-shadow:0 0 0 1px rgba(0,0,0,.08);height:7px;width:7px}.clawpress-status-online .clawpress-status-dot{background:#1a7f37}.clawpress-status-offline .clawpress-status-dot{background:#d63638}.clawpress-status-label{opacity:.9}.clawpress-theme-toggle{align-items:center;background:transparent;border:none;border-radius:999px;color:var(--fp-text);cursor:pointer;display:inline-flex;height:32px;justify-content:center;margin-left:auto;margin-right:6px;padding:4px;transition:color .15s ease;width:32px}.clawpress-theme-toggle svg{display:block;fill:currentColor;height:24px;stroke:currentColor;stroke-width:0;width:24px}.clawpress-close{align-items:center;background:transparent;border:none;border-radius:999px;color:var(--fp-muted);cursor:pointer;display:inline-flex;height:32px;justify-content:center;transition:color .15s ease;width:32px}.clawpress-close:hover,.clawpress-theme-toggle:hover{color:#72aee6}.clawpress-close svg{display:block;fill:currentColor;height:24px;stroke:currentColor;stroke-width:0;width:24px}.clawpress-messages{background:var(--fp-surface-contrast);border:1px solid var(--fp-border);border-radius:3px;display:flex;flex:1;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;gap:10px;overflow-y:auto;padding:14px 12px}.clawpress-msg-content{border-radius:3px;margin:0;padding:8px 10px;white-space:pre-wrap;word-wrap:break-word;color:var(--fp-text)}.clawpress-card,.clawpress-msg-content{background:var(--fp-bg);border:1px solid var(--fp-border)}.clawpress-card{border-radius:6px;overflow:hidden}.clawpress-card.clawpress-card-error{background:#fff4f4;border:1px solid #d63638}.clawpress-card.clawpress-card-error .clawpress-card-title{color:#8a1f1f}.clawpress-card.clawpress-card-error .clawpress-card-subtitle{color:#a13939}.clawpress-card.clawpress-card-error .clawpress-card-text{color:#7f1d1d}.clawpress-card-emoji{font-size:20px;line-height:1}.clawpress-card-body{min-width:0;width:100%}.clawpress-card-section-title{padding:12px 14px 8px}.clawpress-card-section-subtitle{padding:0 14px 10px}.clawpress-card-section-buttons,.clawpress-card-section-content{padding:0 14px 14px}.clawpress-card-title{color:var(--fp-text);font-weight:600;margin:0}.clawpress-card-subtitle{color:var(--fp-muted);font-size:11px;letter-spacing:.04em;margin:0;text-transform:uppercase}.clawpress-card-text{color:var(--fp-text);margin:0;white-space:pre-line}.clawpress-card-welcome-title-line{align-items:center;display:flex;gap:8px}.clawpress-card-actions{display:flex;flex-wrap:wrap;gap:6px;margin:0}.clawpress-msg-label{color:var(--fp-muted);font-size:10px;letter-spacing:.04em;margin-bottom:4px;text-transform:uppercase}.clawpress-msg.clawpress-user .clawpress-msg-content{background:var(--fp-user-bg)}.clawpress-msg.clawpress-user{align-self:flex-end;max-width:85%}.clawpress-msg.clawpress-assistant .clawpress-msg-content{background:var(--fp-assistant-bg);border:1px solid var(--fp-border)}.clawpress-msg.clawpress-assistant{align-self:flex-start;max-width:85%}.clawpress-msg.clawpress-system{align-self:stretch}.clawpress-msg.clawpress-system .clawpress-msg-content{background:var(--fp-system-bg);border:1px dashed var(--fp-border);font-family:Courier New,Courier,monospace;font-style:normal;opacity:1}.clawpress-msg.clawpress-tool_planning .clawpress-msg-content{background:transparent;border:none;font-style:italic;opacity:.85;padding-left:0;padding-right:0}.clawpress-msg-content.clawpress-thinking:after{animation:clawpress-thinking-dots 1.2s steps(3) infinite;clip-path:inset(0 100% 0 0);content:"...";display:inline-block;line-height:1;margin-left:2px;overflow:hidden;vertical-align:baseline;white-space:nowrap;width:1.2em}@keyframes clawpress-thinking-dots{0%{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 0 0 0)}}.clawpress-input{background:var(--fp-surface);display:flex;flex-direction:column;gap:6px;padding:6px 0}.clawpress-input textarea{background:var(--fp-input-bg);border:1px solid var(--fp-border);color:var(--fp-text);font-size:14px;height:60px;padding:5px;resize:none;width:100%}.clawpress-input-footer{align-items:center;display:flex;justify-content:space-between;min-height:28px}.clawpress-context-slot{align-items:center;display:flex;min-height:22px}.clawpress-context-indicator{align-items:center;border-radius:999px;color:var(--fp-muted);cursor:default;display:inline-flex;height:18px;justify-content:center;outline:none;position:relative;width:18px}.clawpress-context-pie{--clawpress-context-used:0%;background:conic-gradient(var(--fp-accent) var(--clawpress-context-used),var(--fp-surface-contrast) 0);border:1px solid var(--fp-border);border-radius:50%;display:inline-block;height:14px;position:relative;width:14px}.clawpress-context-pie:after{background:var(--fp-surface);border-radius:50%;content:"";height:6px;left:3px;position:absolute;top:3px;width:6px}.clawpress-context-tooltip{background:rgba(30,30,32,.95);border:1px solid hsla(0,0%,100%,.14);border-radius:12px;bottom:calc(100% + 8px);box-shadow:0 10px 24px rgba(0,0,0,.32);color:#fff;left:0;opacity:0;padding:12px 14px;pointer-events:none;position:absolute;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease;width:220px;z-index:3}.clawpress-context-indicator:focus-visible .clawpress-context-tooltip,.clawpress-context-indicator:hover .clawpress-context-tooltip{opacity:1;transform:translateY(0)}.clawpress-context-tooltip-title{color:hsla(0,0%,100%,.84);font-size:13px;font-weight:600;margin-bottom:4px}.clawpress-context-tooltip-line{color:#fff;font-size:12px;font-weight:600;line-height:1.45}.clawpress-context-tooltip-note{color:hsla(0,0%,100%,.88);font-size:11px;font-weight:500;line-height:1.35;margin-top:8px}.clawpress-suggestions{display:flex;flex-direction:column;gap:4px}.clawpress-suggestions-label{color:var(--fp-muted);font-size:11px}.clawpress-suggestions-list{display:flex;flex-wrap:wrap;gap:4px}.clawpress-suggestion{font-size:11px;line-height:1.5;padding:2px 6px}.clawpress-tool-dialog{background:var(--fp-surface);border:1px solid var(--fp-border);border-radius:4px;color:var(--fp-text);margin:0;position:relative}.clawpress-tool-dialog-summary{align-items:center;display:flex;gap:8px;justify-content:space-between;padding:10px}.clawpress-tool-dialog-heading{align-items:center;display:flex;flex:1 1 auto;gap:8px;min-width:0}.clawpress-tool-dialog-title{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.clawpress-tool-dialog-status{background:var(--fp-muted);border:1px solid var(--fp-border);border-radius:50%;flex:0 0 10px;height:10px;width:10px}.clawpress-tool-dialog-status-idle{background:var(--fp-muted)}.clawpress-tool-dialog-status-running{background:var(--fp-accent)}.clawpress-tool-dialog-status-blocked{background:light-dark(#dba617,#f0c33c)}.clawpress-tool-dialog-status-error{background:var(--fp-danger)}.clawpress-tool-dialog-status-done{background:light-dark(#00a32a,#00d084)}.clawpress-tool-dialog-status-cancelled{background:light-dark(#8c8f94,#9ea3a8)}.clawpress-tool-dialog-actions{align-items:center;display:inline-flex;flex:0 0 auto;gap:6px}.clawpress-tool-dialog-error-icon{align-items:center;color:var(--fp-danger);display:inline-flex;height:18px;justify-content:center;width:18px}.clawpress-tool-dialog-error-icon svg{fill:currentColor;height:18px;width:18px}.clawpress-tool-dialog-body{background:var(--fp-surface-contrast);border-bottom-left-radius:4px;border-bottom-right-radius:4px;border-top:1px solid var(--fp-border);padding:10px}.clawpress-tool-dialog-body p{font-size:13px;line-height:1.5;margin:8px 0}.clawpress-tool-dialog-form{display:flex;flex-direction:column;gap:10px}.clawpress-tool-dialog-field{display:flex;flex-direction:column;gap:4px}.clawpress-tool-dialog-label{font-size:12px;font-weight:600}.clawpress-tool-dialog-input{font-size:13px}.clawpress-tool-dialog-help{color:var(--fp-muted);font-size:12px}.clawpress-tool-dialog-form-actions{display:flex;gap:6px}.clawpress-tool-result{display:flex;flex-direction:column;gap:10px}.clawpress-tool-result-summary{color:var(--fp-muted);display:flex;flex-wrap:wrap;font-size:12px;gap:12px}.clawpress-tool-result-list{background:var(--fp-bg);border:1px solid var(--fp-border);border-radius:4px;padding:6px 8px}.clawpress-tool-result-list summary{cursor:pointer;font-weight:600;margin-bottom:6px}.clawpress-tool-result-list ul{display:flex;flex-direction:column;gap:6px;list-style:none;margin:0;padding:0}.clawpress-tool-result-list li{display:flex;flex-wrap:wrap;font-size:13px;gap:8px}.clawpress-tool-result-title{font-weight:600}.clawpress-tool-result-meta{color:var(--fp-muted)}.clawpress-tool-result-note{color:var(--fp-muted);font-style:italic;margin:0}.clawpress-tool-result-actions{display:flex;gap:6px}.clawpress-tool-dialog syntax-highlight{background:transparent;display:block}.clawpress-tool-dialog syntax-highlight,.clawpress-tool-dialog-code{border:1px solid var(--fp-border);border-radius:4px;overflow-x:auto;padding:8px}.clawpress-tool-dialog-code{background:var(--fp-bg);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;white-space:pre-wrap}.clawpress-tool-dialog-diff{margin-top:8px}.clawpress-card-setup{width:100%}.clawpress-card-setup-title-line{align-items:center;display:flex;gap:6px}.clawpress-card-setup-emoji{font-size:16px;line-height:1}.clawpress-card-setup-step-line{margin:0}.clawpress-card-setup-progress{align-items:center;display:flex;margin-top:6px;max-width:100%;width:320px}.clawpress-card-setup-progress-item{align-items:center;display:flex;flex:1 1 auto;min-width:0}.clawpress-card-setup-progress-dot{align-items:center;background:var(--fp-bg);border:2px solid var(--fp-border);border-radius:999px;box-sizing:border-box;color:var(--fp-muted);display:flex;flex:0 0 auto;font-size:10px;font-weight:600;height:18px;justify-content:center;line-height:1;width:18px}.clawpress-card-setup-progress-line{background:var(--fp-border);flex:1 1 auto;height:2px}.clawpress-card-setup-progress-completed .clawpress-card-setup-progress-dot,.clawpress-card-setup-progress-done .clawpress-card-setup-progress-dot{background:var(--fp-accent);border-color:var(--fp-accent);color:#fff}.clawpress-card-setup-progress-completed .clawpress-card-setup-progress-line,.clawpress-card-setup-progress-done .clawpress-card-setup-progress-line{background:var(--fp-accent)}.clawpress-card-setup-progress-current .clawpress-card-setup-progress-dot{background:var(--fp-bg);border-color:var(--fp-accent);color:var(--fp-accent)}.clawpress-card-setup-field-list{display:flex;flex-direction:column;gap:6px;margin-top:8px}.clawpress-card-setup-field-row{align-items:flex-start;display:flex;gap:8px}.clawpress-card-setup-field-label{color:var(--fp-muted);font-size:12px;line-height:1.4;min-width:52px;padding-top:8px}.clawpress-card-setup-field-value{background:var(--fp-surface);border:1px solid var(--fp-border);border-radius:4px;color:var(--fp-text);font-family:Courier New,Courier,monospace;font-size:12px;margin:0;padding:4px 8px;white-space:pre-wrap;word-break:break-word}.clawpress-card-setup-detail{color:var(--fp-muted);font-size:12px;margin-top:8px;white-space:pre-line}.clawpress-card-setup-error{color:var(--fp-danger);font-size:12px;margin-top:8px;white-space:pre-line}.clawpress-card-setup-buttons{display:flex;flex-direction:column;gap:8px}.clawpress-card-setup-inline-controls{align-items:center;display:flex;gap:6px}.clawpress-card-setup-select{max-width:100%;min-width:180px}.clawpress-card-setup-input{max-width:100%;min-width:220px}.clawpress-card-user-confirmation{background:#fff8e5;border:1px solid #dba617}.clawpress-card-user-confirmation .clawpress-card-title{color:#8a5b00}.clawpress-card-user-confirmation .clawpress-card-subtitle{color:#9a6c10}.clawpress-card-user-confirmation .clawpress-card-text{color:#6d4d00} +.clawpress-mock-panel,.clawpress-panel{--fp-bg:light-dark(#fff,#1d2327);--fp-surface:light-dark(#f6f7f7,#1d2327);--fp-surface-contrast:light-dark(#fff,#2c3338);--fp-border:light-dark(#dcdcde,#1d2327);--fp-text:light-dark(#1d2327,#f0f0f1);--fp-muted:light-dark(#646970,#a7aaad);--fp-accent:light-dark(#2271b1,#72aee6);--fp-user-bg:light-dark(#e8f4ff,#22384f);--fp-assistant-bg:light-dark(#f4f4f4,#263743);--fp-system-bg:light-dark(#fff6e5,#3a2f1c);--fp-tool-bg:light-dark(#e5eaff,#1f2f3d);--fp-tool-border:light-dark(#6c63ff,#9bb0ff);--fp-input-bg:light-dark(#fff,#2b3238);--fp-danger:light-dark(#d63638,#ff8a8a)}.clawpress-panel{background:var(--fp-bg);border-left:1px solid var(--fp-border);border-right:1px solid var(--fp-border);box-shadow:-2px 0 5px rgba(0,0,0,.05);box-sizing:border-box;color:var(--fp-text);display:flex;flex-direction:column;height:calc(100vh - var(--wp-admin--admin-bar--height, 0px));opacity:0;padding-left:6px;padding-right:6px;pointer-events:none;position:fixed;right:0;top:var(--wp-admin--admin-bar--height,0);transform:translateX(100%);transition:transform .25s cubic-bezier(.4,0,.2,1),opacity .18s ease,width .2s ease;width:400px;will-change:transform,opacity;z-index:1000}.clawpress-panel:after{background:var(--fp-surface);bottom:0;content:"";pointer-events:none;position:absolute;right:0;top:0;width:6px;z-index:0}.clawpress-panel>*{position:relative;z-index:1}.clawpress-panel[data-theme=light]{color-scheme:light}.clawpress-panel[data-theme=dark]{color-scheme:dark}.clawpress-panel[data-theme=light] syntax-highlight{color-scheme:light}.clawpress-panel[data-theme=dark] syntax-highlight{color-scheme:dark}body.clawpress-panel-open .clawpress-panel{opacity:1;pointer-events:auto;transform:translateX(0)}body.clawpress-panel-open #wpcontent{margin-right:var(--clawpress-panel-width,400px);transition:margin-right .25s cubic-bezier(.4,0,.2,1)}.clawpress-mock-panel{background:var(--fp-surface,#fff);border:1px solid var(--fp-border,#ddd);border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,.08);color:var(--fp-text,#1d2327);display:flex;flex-direction:column;gap:6px;padding:8px;position:fixed;right:calc(var(--clawpress-panel-width, 400px) + 12px);top:calc(var(--wp-admin--admin-bar--height, 0px) + 10px);z-index:1001}.clawpress-mock-panel[data-theme=light]{color-scheme:light}.clawpress-mock-panel[data-theme=dark]{color-scheme:dark}.clawpress-mock-panel[data-theme=light] syntax-highlight{color-scheme:light}.clawpress-mock-panel[data-theme=dark] syntax-highlight{color-scheme:dark}.clawpress-mock-badge{align-self:flex-start;background:var(--fp-text,#1d2327);border-radius:10px;color:var(--fp-bg,#fff);font-size:11px;letter-spacing:.04em;padding:2px 6px;text-transform:uppercase}.clawpress-mock-buttons{display:flex;flex-wrap:wrap;gap:4px}.clawpress-mock-fieldset{border:1px solid var(--fp-border,#ddd);border-radius:6px;margin:0;min-width:0;padding:6px}.clawpress-mock-fieldset legend{font-size:11px;font-weight:600;padding:0 4px}.clawpress-drag-handle{background:var(--fp-surface);bottom:0;cursor:ew-resize;left:0;position:absolute;top:0;width:6px;z-index:0}@media(prefers-reduced-motion:reduce){.clawpress-panel,body.clawpress-panel-open #wpcontent{transition:none!important}}.clawpress-toggle{bottom:20px;position:fixed;right:20px;transition:opacity .2s ease;z-index:999}body.clawpress-panel-open .clawpress-toggle{display:none}.clawpress-header{align-items:center;background:var(--fp-surface);display:flex;gap:8px;justify-content:flex-start;padding:10px}.clawpress-header-meta{display:flex;flex-direction:column;gap:2px}.clawpress-title{color:var(--fp-text);font-size:13px;font-weight:400}.clawpress-status{align-items:center;color:var(--fp-muted);display:inline-flex;font-size:11px;gap:6px}.clawpress-status-dot{background:#d63638;border-radius:999px;box-shadow:0 0 0 1px rgba(0,0,0,.08);height:7px;width:7px}.clawpress-status-online .clawpress-status-dot{background:#1a7f37}.clawpress-status-offline .clawpress-status-dot{background:#d63638}.clawpress-status-label{opacity:.9}.clawpress-theme-toggle{align-items:center;background:transparent;border:none;border-radius:999px;color:var(--fp-text);cursor:pointer;display:inline-flex;height:32px;justify-content:center;margin-left:auto;margin-right:6px;padding:4px;transition:color .15s ease;width:32px}.clawpress-theme-toggle svg{display:block;fill:currentColor;height:24px;stroke:currentColor;stroke-width:0;width:24px}.clawpress-close{align-items:center;background:transparent;border:none;border-radius:999px;color:var(--fp-muted);cursor:pointer;display:inline-flex;height:32px;justify-content:center;transition:color .15s ease;width:32px}.clawpress-close:hover,.clawpress-theme-toggle:hover{color:#72aee6}.clawpress-close svg{display:block;fill:currentColor;height:24px;stroke:currentColor;stroke-width:0;width:24px}.clawpress-messages{background:var(--fp-surface-contrast);border:1px solid var(--fp-border);border-radius:3px;display:flex;flex:1;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;gap:10px;overflow-y:auto;padding:14px 12px}.clawpress-msg-content{border-radius:3px;margin:0;padding:8px 10px;white-space:pre-wrap;word-wrap:break-word;color:var(--fp-text)}.clawpress-card,.clawpress-msg-content{background:var(--fp-bg);border:1px solid var(--fp-border)}.clawpress-card{border-radius:6px;overflow:hidden}.clawpress-card.clawpress-card-error{background:#fff4f4;border:1px solid #d63638}.clawpress-card.clawpress-card-error .clawpress-card-title{color:#8a1f1f}.clawpress-card.clawpress-card-error .clawpress-card-subtitle{color:#a13939}.clawpress-card.clawpress-card-error .clawpress-card-text{color:#7f1d1d}.clawpress-card-emoji{font-size:20px;line-height:1}.clawpress-card-body{min-width:0;width:100%}.clawpress-card-section-title{padding:12px 14px 8px}.clawpress-card-section-subtitle{padding:0 14px 10px}.clawpress-card-section-buttons,.clawpress-card-section-content{padding:0 14px 14px}.clawpress-card-title{color:var(--fp-text);font-weight:600;margin:0}.clawpress-card-subtitle{color:var(--fp-muted);font-size:11px;letter-spacing:.04em;margin:0;text-transform:uppercase}.clawpress-card-text{color:var(--fp-text);margin:0;white-space:pre-line}.clawpress-card-welcome-title-line{align-items:center;display:flex;gap:8px}.clawpress-card-actions{display:flex;flex-wrap:wrap;gap:6px;margin:0}.clawpress-msg-label{color:var(--fp-muted);font-size:10px;letter-spacing:.04em;margin-bottom:4px;text-transform:uppercase}.clawpress-msg.clawpress-user .clawpress-msg-content{background:var(--fp-user-bg)}.clawpress-msg.clawpress-user{align-self:flex-end;max-width:85%}.clawpress-msg.clawpress-assistant .clawpress-msg-content{background:var(--fp-assistant-bg);border:1px solid var(--fp-border)}.clawpress-msg.clawpress-assistant{align-self:flex-start;max-width:85%}.clawpress-msg.clawpress-system{align-self:stretch}.clawpress-msg.clawpress-system .clawpress-msg-content{background:var(--fp-system-bg);border:1px dashed var(--fp-border);font-family:Courier New,Courier,monospace;font-style:normal;opacity:1}.clawpress-msg.clawpress-tool_planning .clawpress-msg-content{background:transparent;border:none;font-style:italic;opacity:.85;padding-left:0;padding-right:0}.clawpress-msg-content.clawpress-thinking:after{animation:clawpress-thinking-dots 1.2s step-end infinite;content:"...";display:inline-block;line-height:1;margin-left:2px;overflow:hidden;vertical-align:baseline;white-space:nowrap;width:1ch}@keyframes clawpress-thinking-dots{0%,24.99%{width:1ch}25%,49.99%{width:2ch}50%,74.99%{width:3ch}75%,to{width:1ch}}.clawpress-input{background:var(--fp-surface);display:flex;flex-direction:column;gap:6px;padding:6px 0}.clawpress-input textarea{background:var(--fp-input-bg);border:1px solid var(--fp-border);color:var(--fp-text);font-size:14px;height:60px;padding:5px;resize:none;width:100%}.clawpress-input-footer{align-items:center;display:flex;justify-content:space-between;min-height:28px}.clawpress-context-slot{align-items:center;display:flex;min-height:22px}.clawpress-context-indicator{align-items:center;border-radius:999px;color:var(--fp-muted);cursor:default;display:inline-flex;height:18px;justify-content:center;outline:none;position:relative;width:18px}.clawpress-context-pie{--clawpress-context-used:0%;background:conic-gradient(var(--fp-accent) var(--clawpress-context-used),var(--fp-surface-contrast) 0);border:1px solid var(--fp-border);border-radius:50%;display:inline-block;height:14px;position:relative;width:14px}.clawpress-context-pie:after{background:var(--fp-surface);border-radius:50%;content:"";height:6px;left:3px;position:absolute;top:3px;width:6px}.clawpress-context-tooltip{background:rgba(30,30,32,.95);border:1px solid hsla(0,0%,100%,.14);border-radius:12px;bottom:calc(100% + 8px);box-shadow:0 10px 24px rgba(0,0,0,.32);color:#fff;left:0;opacity:0;padding:12px 14px;pointer-events:none;position:absolute;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease;width:220px;z-index:3}.clawpress-context-indicator:focus-visible .clawpress-context-tooltip,.clawpress-context-indicator:hover .clawpress-context-tooltip{opacity:1;transform:translateY(0)}.clawpress-context-tooltip-title{color:hsla(0,0%,100%,.84);font-size:13px;font-weight:600;margin-bottom:4px}.clawpress-context-tooltip-line{color:#fff;font-size:12px;font-weight:600;line-height:1.45}.clawpress-context-tooltip-note{color:hsla(0,0%,100%,.88);font-size:11px;font-weight:500;line-height:1.35;margin-top:8px}.clawpress-suggestions{display:flex;flex-direction:column;gap:4px}.clawpress-suggestions-label{color:var(--fp-muted);font-size:11px}.clawpress-suggestions-list{display:flex;flex-wrap:wrap;gap:4px}.clawpress-suggestion{font-size:11px;line-height:1.5;padding:2px 6px}.clawpress-tool-dialog{background:var(--fp-surface);border:1px solid var(--fp-border);border-radius:4px;color:var(--fp-text);margin:0;position:relative}.clawpress-tool-dialog-summary{align-items:center;display:flex;gap:8px;justify-content:space-between;padding:10px}.clawpress-tool-dialog-heading{align-items:center;display:flex;flex:1 1 auto;gap:8px;min-width:0}.clawpress-tool-dialog-title{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.clawpress-tool-dialog-status{background:var(--fp-muted);border:1px solid var(--fp-border);border-radius:50%;flex:0 0 10px;height:10px;width:10px}.clawpress-tool-dialog-status-idle{background:var(--fp-muted)}.clawpress-tool-dialog-status-running{background:var(--fp-accent)}.clawpress-tool-dialog-status-blocked{background:light-dark(#dba617,#f0c33c)}.clawpress-tool-dialog-status-error{background:var(--fp-danger)}.clawpress-tool-dialog-status-done{background:light-dark(#00a32a,#00d084)}.clawpress-tool-dialog-status-cancelled{background:light-dark(#8c8f94,#9ea3a8)}.clawpress-tool-dialog-actions{align-items:center;display:inline-flex;flex:0 0 auto;gap:6px}.clawpress-tool-dialog-error-icon{align-items:center;color:var(--fp-danger);display:inline-flex;height:18px;justify-content:center;width:18px}.clawpress-tool-dialog-error-icon svg{fill:currentColor;height:18px;width:18px}.clawpress-tool-dialog-body{background:var(--fp-surface-contrast);border-bottom-left-radius:4px;border-bottom-right-radius:4px;border-top:1px solid var(--fp-border);padding:10px}.clawpress-tool-dialog-body p{font-size:13px;line-height:1.5;margin:8px 0}.clawpress-tool-dialog-form{display:flex;flex-direction:column;gap:10px}.clawpress-tool-dialog-field{display:flex;flex-direction:column;gap:4px}.clawpress-tool-dialog-label{font-size:12px;font-weight:600}.clawpress-tool-dialog-input{font-size:13px}.clawpress-tool-dialog-help{color:var(--fp-muted);font-size:12px}.clawpress-tool-dialog-form-actions{display:flex;gap:6px}.clawpress-tool-result{display:flex;flex-direction:column;gap:10px}.clawpress-tool-result-summary{color:var(--fp-muted);display:flex;flex-wrap:wrap;font-size:12px;gap:12px}.clawpress-tool-result-list{background:var(--fp-bg);border:1px solid var(--fp-border);border-radius:4px;padding:6px 8px}.clawpress-tool-result-list summary{cursor:pointer;font-weight:600;margin-bottom:6px}.clawpress-tool-result-list ul{display:flex;flex-direction:column;gap:6px;list-style:none;margin:0;padding:0}.clawpress-tool-result-list li{display:flex;flex-wrap:wrap;font-size:13px;gap:8px}.clawpress-tool-result-title{font-weight:600}.clawpress-tool-result-meta{color:var(--fp-muted)}.clawpress-tool-result-note{color:var(--fp-muted);font-style:italic;margin:0}.clawpress-tool-result-actions{display:flex;gap:6px}.clawpress-tool-dialog syntax-highlight{background:transparent;display:block}.clawpress-tool-dialog syntax-highlight,.clawpress-tool-dialog-code{border:1px solid var(--fp-border);border-radius:4px;overflow-x:auto;padding:8px}.clawpress-tool-dialog-code{background:var(--fp-bg);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;white-space:pre-wrap}.clawpress-tool-dialog-diff{margin-top:8px}.clawpress-card-setup{width:100%}.clawpress-card-setup-title-line{align-items:center;display:flex;gap:6px}.clawpress-card-setup-emoji{font-size:16px;line-height:1}.clawpress-card-setup-step-line{margin:0}.clawpress-card-setup-progress{align-items:center;display:flex;margin-top:6px;max-width:100%;width:320px}.clawpress-card-setup-progress-item{align-items:center;display:flex;flex:1 1 auto;min-width:0}.clawpress-card-setup-progress-dot{align-items:center;background:var(--fp-bg);border:2px solid var(--fp-border);border-radius:999px;box-sizing:border-box;color:var(--fp-muted);display:flex;flex:0 0 auto;font-size:10px;font-weight:600;height:18px;justify-content:center;line-height:1;width:18px}.clawpress-card-setup-progress-line{background:var(--fp-border);flex:1 1 auto;height:2px}.clawpress-card-setup-progress-completed .clawpress-card-setup-progress-dot,.clawpress-card-setup-progress-done .clawpress-card-setup-progress-dot{background:var(--fp-accent);border-color:var(--fp-accent);color:#fff}.clawpress-card-setup-progress-completed .clawpress-card-setup-progress-line,.clawpress-card-setup-progress-done .clawpress-card-setup-progress-line{background:var(--fp-accent)}.clawpress-card-setup-progress-current .clawpress-card-setup-progress-dot{background:var(--fp-bg);border-color:var(--fp-accent);color:var(--fp-accent)}.clawpress-card-setup-field-list{display:flex;flex-direction:column;gap:6px;margin-top:8px}.clawpress-card-setup-field-row{align-items:flex-start;display:flex;gap:8px}.clawpress-card-setup-field-label{color:var(--fp-muted);font-size:12px;line-height:1.4;min-width:52px;padding-top:8px}.clawpress-card-setup-field-value{background:var(--fp-surface);border:1px solid var(--fp-border);border-radius:4px;color:var(--fp-text);font-family:Courier New,Courier,monospace;font-size:12px;margin:0;padding:4px 8px;white-space:pre-wrap;word-break:break-word}.clawpress-card-setup-detail{color:var(--fp-muted);font-size:12px;margin-top:8px;white-space:pre-line}.clawpress-card-setup-error{color:var(--fp-danger);font-size:12px;margin-top:8px;white-space:pre-line}.clawpress-card-setup-buttons{display:flex;flex-direction:column;gap:8px}.clawpress-card-setup-inline-controls{align-items:center;display:flex;gap:6px}.clawpress-card-setup-select{max-width:100%;min-width:180px}.clawpress-card-setup-input{max-width:100%;min-width:220px}.clawpress-card-user-confirmation{background:#fff8e5;border:1px solid #dba617}.clawpress-card-user-confirmation .clawpress-card-title{color:#8a5b00}.clawpress-card-user-confirmation .clawpress-card-subtitle{color:#9a6c10}.clawpress-card-user-confirmation .clawpress-card-text{color:#6d4d00} diff --git a/build/panel/panel.js b/build/panel/panel.js index 9fa155b..556d4c3 100644 --- a/build/panel/panel.js +++ b/build/panel/panel.js @@ -1,15 +1,15 @@ -(()=>{"use strict";const e=window.wp.element,s=window.wp.i18n,t=window.ReactJSXRuntime,r=e=>"online"===e?(0,s.__)("Online","clawpress"):"offline"===e?(0,s.__)("Offline","clawpress"):e,a=({onClose:e,onToggleTheme:a,statusMode:n,statusLabel:l,statusLoading:o})=>{const c=n||"offline";return(0,t.jsxs)("div",{className:"clawpress-header",children:[(0,t.jsxs)("div",{className:"clawpress-header-meta",children:[(0,t.jsx)("div",{className:"clawpress-title",children:(0,s.__)("ClawPress Agent","clawpress")}),(0,t.jsxs)("div",{className:`clawpress-status clawpress-status-${c}`,children:[(0,t.jsx)("span",{className:"clawpress-status-dot"}),(0,t.jsx)("span",{className:"clawpress-status-mode",children:o?(0,s.__)("Checking…","clawpress"):r(c)}),l?(0,t.jsx)("span",{className:"clawpress-status-label",children:l}):null]})]}),(0,t.jsx)("button",{className:"clawpress-theme-toggle",type:"button",onClick:a,"aria-label":(0,s.__)("Toggle theme","clawpress"),children:(0,t.jsx)("svg",{viewBox:"0 0 24 24","aria-hidden":"true",focusable:"false",children:(0,t.jsx)("path",{d:"M12 18q2.484 0 4.242-1.758t1.758-4.242-1.758-4.242-4.242-1.758q-1.219 0-2.484 0.563 1.547 0.703 2.508 2.18t0.961 3.258-0.961 3.258-2.508 2.18q1.266 0.563 2.484 0.563zM20.016 8.672l3.281 3.328-3.281 3.328v4.688h-4.688l-3.328 3.281-3.328-3.281h-4.688v-4.688l-3.281-3.328 3.281-3.328v-4.688h4.688l3.328-3.281 3.328 3.281h4.688v4.688z"})})}),(0,t.jsx)("button",{className:"clawpress-close",onClick:e,type:"button","aria-label":(0,s.__)("Close panel","clawpress"),children:(0,t.jsx)("svg",{viewBox:"0 0 24 24","aria-hidden":"true",focusable:"false",children:(0,t.jsx)("path",{d:"M18.984 6.422l-5.578 5.578 5.578 5.578-1.406 1.406-5.578-5.578-5.578 5.578-1.406-1.406 5.578-5.578-5.578-5.578 1.406-1.406 5.578 5.578 5.578-5.578z"})})})]})},n=e=>{const s=Number(e);if(!Number.isFinite(s)||s<=0)return"0";if(s<1e3)return String(Math.round(s));const t=[{threshold:1e9,suffix:"b"},{threshold:1e6,suffix:"m"},{threshold:1e3,suffix:"k"}];for(const e of t)if(s>=e.threshold){const t=s/e.threshold,r=t>=100?0:1;return`${t.toFixed(r).replace(/\.0$/,"")}${e.suffix}`}return String(Math.round(s))},l=({input:r,onInputChange:a,onSend:l,onStop:o,streaming:c,suggestions:i,contextUsage:d,onSendSuggestion:u,onHistoryUp:p,onHistoryDown:m})=>{const h=(0,e.useRef)(null),g=Number(d?.usedTokens),w=Number(d?.contextWindowTokens),y=Number(d?.percentUsed),f=Number.isFinite(g)&&g>=0?Math.round(g):0,_=Number.isFinite(w)&&w>0?Math.round(w):0;let b=null;Number.isFinite(y)&&y>=0?b=Math.max(0,Math.min(100,Math.round(y))):_>0&&(b=Math.max(0,Math.min(100,Math.round(f/_*100))));const x=null===b?null:Math.max(0,100-b),j=_>0&&null!==b&&f>=0,N=j&&null!==x?(0,s.sprintf)(/* translators: 1: percentage used, 2: percentage left */ /* translators: 1: percentage used, 2: percentage left */ -(0,s.__)("%1$d%% used (%2$d%% left)","clawpress"),b,x):"",v=j?(0,s.sprintf)(/* translators: 1: used tokens, 2: available context-window tokens */ /* translators: 1: used tokens, 2: available context-window tokens */ -(0,s.__)("%1$s / %2$s tokens used","clawpress"),n(f),n(_)):"";return(0,t.jsxs)("div",{className:"clawpress-input",children:[Array.isArray(i)&&i.length>0?(0,t.jsxs)("div",{className:"clawpress-suggestions","aria-label":(0,s.__)("Suggestions","clawpress"),children:[(0,t.jsx)("div",{className:"clawpress-suggestions-label",children:(0,s.__)("Suggestions","clawpress")}),(0,t.jsx)("div",{className:"clawpress-suggestions-list",children:i.map(e=>(0,t.jsx)("button",{className:"clawpress-suggestion button button-secondary",onClick:()=>u?.(e),type:"button",disabled:c,children:e},e))})]}):null,(0,t.jsx)("textarea",{ref:h,value:r,onChange:a,onKeyDown:e=>{if("Enter"!==e.key||e.shiftKey||(e.preventDefault(),l()),"ArrowUp"===e.key){const s=h.current,t=s?.selectionStart??0,n=!r.slice(0,t).includes("\n");if((""===r.trim()||n)&&p){e.preventDefault();const s=p(r);if(null==s)return;a({target:{value:s}}),setTimeout(()=>{if(!h.current)return;const e=s.length;h.current.setSelectionRange(e,e)},0)}}if("ArrowDown"===e.key){const s=h.current,t=s?.selectionStart??0,n=!r.slice(t).includes("\n");if((""===r.trim()||n)&&m){e.preventDefault();const s=m();if(null==s)return;a({target:{value:s}}),setTimeout(()=>{if(!h.current)return;const e=s.length;h.current.setSelectionRange(e,e)},0)}}},placeholder:(0,s.__)("Ask me anything…","clawpress"),disabled:c}),(0,t.jsxs)("div",{className:"clawpress-input-footer",children:[(0,t.jsx)("div",{className:"clawpress-context-slot",children:j?(0,t.jsxs)("div",{className:"clawpress-context-indicator",role:"img",tabIndex:0,"aria-label":(0,s.sprintf)(/* translators: 1: context usage summary, 2: token usage summary */ /* translators: 1: context usage summary, 2: token usage summary */ -(0,s.__)("Context window: %1$s. %2$s.","clawpress"),N,v),children:[(0,t.jsx)("span",{className:"clawpress-context-pie",style:{"--clawpress-context-used":`${b}%`},"aria-hidden":"true"}),(0,t.jsxs)("div",{className:"clawpress-context-tooltip",role:"tooltip",children:[(0,t.jsx)("div",{className:"clawpress-context-tooltip-title",children:(0,s.__)("Context window:","clawpress")}),(0,t.jsx)("div",{className:"clawpress-context-tooltip-line",children:N}),(0,t.jsx)("div",{className:"clawpress-context-tooltip-line",children:v}),(0,t.jsx)("div",{className:"clawpress-context-tooltip-note",children:(0,s.__)("Codex automatically compacts its context","clawpress")})]})]}):null}),c?(0,t.jsx)("button",{className:"button",onClick:o,type:"button",children:(0,s.__)("Stop","clawpress")}):(0,t.jsx)("button",{className:"button button-primary",onClick:l,type:"button",children:(0,s.__)("Send","clawpress")})]})]})},o=e=>{if(!e||void 0===e.arguments||null===e.arguments)return{};if("string"==typeof e.arguments){if(""===e.arguments.trim())return{};try{return JSON.parse(e.arguments)}catch{return{raw:e.arguments}}}return e.arguments},c=({status:e,title:r,canRerun:a,policy:n,isOpen:l,error:o,children:c,onRun:i,onCancel:d,showActions:u,runLabelOverride:p})=>{const m=p||((e,t)=>"running"===e?(0,s.__)("Running…","clawpress"):"blocked"===e?(0,s.__)("Blocked","clawpress"):"done"===e&&t?(0,s.__)("Re-run","clawpress"):"error"===e?(0,s.__)("Retry","clawpress"):(0,s.__)("Run","clawpress"))(e,a),h="cancelled"===e,g="error"===e,w=void 0!==u?u:"running"!==e&&!h&&("done"!==e||a),y="blocked"===e,f=((e,t)=>"running"===e?(0,s.__)("Running…","clawpress"):"blocked"===e?"serial"===t?.concurrency?(0,s.__)("Blocked (waiting for earlier tool).","clawpress"):(0,s.__)("Blocked.","clawpress"):null)(e,n);return(0,t.jsxs)("details",{className:"clawpress-tool-dialog",open:!h&&l,children:[(0,t.jsxs)("summary",{className:"clawpress-tool-dialog-summary",children:[(0,t.jsxs)("span",{className:"clawpress-tool-dialog-heading",children:[(0,t.jsx)("span",{className:`clawpress-tool-dialog-status clawpress-tool-dialog-status-${e}`,"aria-hidden":"true"}),(0,t.jsx)("span",{className:"clawpress-tool-dialog-title",children:r})]}),w?(0,t.jsxs)("span",{className:"clawpress-tool-dialog-actions",children:[g?(0,t.jsx)("span",{className:"clawpress-tool-dialog-error-icon","aria-hidden":"true",children:(0,t.jsx)("svg",{viewBox:"0 0 24 24",role:"img",children:(0,t.jsx)("path",{d:"M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 6a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9a1 1 0 0 1 1-1Zm0 10a1.25 1.25 0 1 1 0-2.5A1.25 1.25 0 0 1 12 18Z"})})}):null,(0,t.jsx)("button",{className:"button button-primary",type:"button",onClick:i,disabled:"running"===e||y||h,children:m}),(0,t.jsx)("button",{className:"button",type:"button",onClick:d,children:(0,s.__)("Cancel","clawpress")})]}):null]}),(0,t.jsxs)("div",{className:"clawpress-tool-dialog-body",children:[h?(0,t.jsx)("p",{children:(0,s.__)("Cancelled.","clawpress")}):null,f?(0,t.jsx)("p",{children:f}):null,"error"===e?(0,t.jsx)("p",{children:o}):null,c]})]})},i=({fields:r,initialValues:a,onSubmit:n,onRun:l,onCancel:o,disabled:c})=>{const[i,d]=(0,e.useState)(a);(0,e.useEffect)(()=>{d(a)},[a]);const u=(e,s)=>d(t=>({...t,[e]:s}));return(0,t.jsxs)("form",{className:"clawpress-tool-dialog-form",onSubmit:e=>{e.preventDefault(),c||n(i)},children:[r.map(e=>{const s=i[e.name],r={id:`clawpress-tool-field-${e.name}`,name:e.name,disabled:c};if("hidden"===e.type)return(0,t.jsx)("input",{...r,type:"hidden",value:s??""},e.name);let a=null;return a="textarea"===e.type?(0,t.jsx)("textarea",{...r,className:"clawpress-tool-dialog-input",rows:e.rows||3,value:s??"",onChange:s=>u(e.name,s.target.value)}):"select"===e.type?(0,t.jsx)("select",{...r,className:"clawpress-tool-dialog-input",value:s??"",onChange:s=>u(e.name,s.target.value),children:e.options?.map(e=>(0,t.jsx)("option",{value:e.value,children:e.label},e.value))}):"checkbox"===e.type?(0,t.jsx)("input",{...r,type:"checkbox",checked:Boolean(s),onChange:s=>u(e.name,s.target.checked)}):(0,t.jsx)("input",{...r,className:"clawpress-tool-dialog-input",type:e.type||"text",value:s??"",onChange:s=>u(e.name,s.target.value)}),(0,t.jsxs)("label",{className:"clawpress-tool-dialog-field",htmlFor:r.id,children:[(0,t.jsx)("span",{className:"clawpress-tool-dialog-label",children:e.label}),a,e.help?(0,t.jsx)("span",{className:"clawpress-tool-dialog-help",children:e.help}):null]},e.name)}),(0,t.jsxs)("div",{className:"clawpress-tool-dialog-form-actions",children:[(0,t.jsx)("button",{className:"button button-primary",type:"submit",disabled:c,children:(0,s.__)("Preview","clawpress")}),l?(0,t.jsx)("button",{className:"button",type:"button",onClick:e=>{e.preventDefault(),c||l&&l(i)},disabled:c,children:(0,s.__)("Run","clawpress")}):null,o?(0,t.jsx)("button",{className:"button",type:"button",onClick:o,disabled:c,children:(0,s.__)("Cancel","clawpress")}):null]})]})},d="update_posts_find_replace",u=({toolName:e,args:r,toolDialog:a,runTool:n,onCancel:l,policy:o,isOpen:i})=>{const d=a.status||"idle",u=a.error||null,p=a.result||null,m=Boolean(o?.canRerun);return(0,t.jsxs)(c,{status:d,title:e||(0,s.__)("Unknown tool","clawpress"),canRerun:m,policy:o,isOpen:i,error:u,onRun:e=>{e.preventDefault(),e.stopPropagation(),n(a.id,a.args||r)},onCancel:e=>{e.preventDefault(),e.stopPropagation(),l(a.id)},children:["done"===d?(0,t.jsx)("syntax-highlight",{language:"json",children:JSON.stringify(p,null,2)}):(0,t.jsx)("syntax-highlight",{language:"json",children:JSON.stringify(r,null,2)}),a.diff?(0,t.jsxs)("div",{className:"clawpress-tool-dialog-diff",children:[(0,t.jsx)("h4",{children:(0,s.__)("Changes:","clawpress")}),(0,t.jsx)("syntax-highlight",{language:"json",children:JSON.stringify(a.diff,null,2)})]}):null]})},p={[d]:{renderer:({args:e,toolDialog:r,runTool:a,onCancel:n,policy:l,isOpen:o})=>{const d=e.search??"",u=e.replace??"",p=e.post_status??"any",m=r.status||"idle",h=r.error||null,g=r.result||null,w="done"===m?Boolean(g?.dry_run):Boolean(l?.canRerun),y="done"===m&&g?.dry_run,f="error"===m,_="blocked"===m,b=g?.total??0,x=Array.isArray(g?.changed)?g.changed:[],j=e=>{a(r.id,e)},N=[{name:"search",label:(0,s.__)("Find","clawpress"),type:"text",help:(0,s.__)("The text to search for.","clawpress")},{name:"replace",label:(0,s.__)("Replace","clawpress"),type:"text",help:(0,s.__)("The text to replace with.","clawpress")},{name:"post_status",label:(0,s.__)("Post status","clawpress"),type:"select",options:[{value:"any",label:(0,s.__)("Any","clawpress")},{value:"publish",label:(0,s.__)("Published","clawpress")},{value:"draft",label:(0,s.__)("Draft","clawpress")}]},{name:"dry_run",label:(0,s.__)("Dry run","clawpress"),type:"hidden"}],v="cancelled"===m?(0,s.__)("Update Posts - Find and Replace (Cancelled)","clawpress"):(0,s.__)("Update Posts - Find and Replace","clawpress");let k=null;return"cancelled"!==m&&(k=f?(0,t.jsxs)("div",{className:"clawpress-tool-result-actions",children:[(0,t.jsx)("button",{className:"button button-primary",type:"button",onClick:()=>j({...r.args||e,dry_run:!1!==r.args?.dry_run}),children:(0,s.__)("Retry","clawpress")}),(0,t.jsx)("button",{className:"button",type:"button",onClick:()=>n(r.id),children:(0,s.__)("Cancel","clawpress")})]}):"done"!==m?(0,t.jsx)(i,{fields:N,initialValues:{search:d,replace:u,post_status:p,dry_run:!0},disabled:"running"===m||_,onSubmit:e=>j({...e,dry_run:!0}),onRun:e=>j({...e,dry_run:!1}),onCancel:()=>n(r.id)}):(0,t.jsxs)("div",{className:"clawpress-tool-result",children:[(0,t.jsx)("div",{className:"clawpress-tool-result-summary",children:(0,t.jsx)("span",{children:y?(0,s.__)("Preview Results","clawpress"):(0,s.__)("Results","clawpress")})}),0===b?(0,t.jsx)("p",{children:(0,s.__)("No matches found.","clawpress")}):(0,t.jsxs)("details",{className:"clawpress-tool-result-list",open:!0,children:[(0,t.jsx)("summary",{children:(0,s.sprintf)(/* translators: %d: number of changed posts */ /* translators: %d: number of changed posts */ -(0,s.__)("Changed posts (%d)","clawpress"),b)}),(0,t.jsx)("ul",{children:x.map(e=>(0,t.jsxs)("li",{children:[(0,t.jsx)("span",{className:"clawpress-tool-result-title",children:e.title||(0,s.__)("Untitled","clawpress")}),(0,t.jsx)("span",{className:"clawpress-tool-result-meta",children:(0,s.sprintf)(/* translators: %d: post ID */ /* translators: %d: post ID */ -(0,s.__)("ID %d","clawpress"),e.id)}),(0,t.jsx)("span",{className:"clawpress-tool-result-meta",children:(0,s.sprintf)(/* translators: %d: number of replacements in a post */ /* translators: %d: number of replacements in a post */ -(0,s._n)("%d change","%d changes",e.count,"clawpress"),e.count)})]},e.id))})]}),y?(0,t.jsxs)("div",{className:"clawpress-tool-result-actions",children:[(0,t.jsx)("button",{className:"button button-primary",type:"button",onClick:()=>j({search:d,replace:u,post_status:p,dry_run:!1}),children:(0,s.__)("Run","clawpress")}),(0,t.jsx)("button",{className:"button",type:"button",onClick:()=>n(r.id),children:(0,s.__)("Cancel","clawpress")})]}):null]})),(0,t.jsx)(c,{status:m,title:v,canRerun:w,policy:l,isOpen:o,error:h,showActions:!1,onRun:e=>{e.preventDefault(),e.stopPropagation(),j({search:d,replace:u,post_status:p,dry_run:!1})},onCancel:e=>{e.preventDefault(),e.stopPropagation(),n(r.id)},children:k})},policy:{concurrency:"serial",canRerun:!1}}},m=e=>p[e]?.policy||{},h=({toolDialog:e,isOpen:s,onRunTool:r,onCancel:a})=>{if(!e)return null;const n=o(e.function),l=e.function?.name||"",c=(e=>p[e]?.renderer||u)(l),i=m(l);return(0,t.jsx)(c,{toolName:l,args:n,toolDialog:e,runTool:r,onCancel:a,policy:i,isOpen:s})},g=e=>Array.isArray(e?.data?.actions)?e.data.actions.filter(e=>e&&"object"==typeof e).map((e,s)=>{const t="string"==typeof e.label?e.label.trim():"",r="string"==typeof e.type&&e.type.trim()?e.type.trim().toLowerCase():"send_prompt";if(!t)return null;if("open_url"===r){const r=[e.url,e.href].map(e=>"string"==typeof e?e.trim():"").find(e=>e.length>0);return r?{id:"string"==typeof e.id&&e.id.trim()?e.id.trim():`action-${s}`,label:t,type:"open_url",url:r}:null}if("run_tool"===r){const r=[e.tool,e.tool_name,e.name].map(e=>"string"==typeof e?e.trim():"").find(e=>e.length>0);if(!r)return null;let a={};if(e.args&&"object"==typeof e.args&&!Array.isArray(e.args))a=e.args;else if("string"==typeof e.arguments)try{const s=JSON.parse(e.arguments);s&&"object"==typeof s&&!Array.isArray(s)&&(a=s)}catch{a={}}else e.arguments&&"object"==typeof e.arguments&&!Array.isArray(e.arguments)&&(a=e.arguments);return{id:"string"==typeof e.id&&e.id.trim()?e.id.trim():`action-${s}`,label:t,type:"run_tool",tool:r,args:a}}const a=[e.prompt,e.message,e.command].map(e=>"string"==typeof e?e.trim():"").find(e=>e.length>0);return a?{id:"string"==typeof e.id&&e.id.trim()?e.id.trim():`action-${s}`,label:t,type:"send_prompt",prompt:a}:null}).filter(e=>Boolean(e)):[],w=({card:e,onSendAction:r,isBusy:a=!1})=>{const n="string"==typeof e?.data?.title&&e.data.title.trim()?e.data.title:(0,s.__)("Welcome to ClawPress","clawpress"),l="string"==typeof e?.data?.message&&e.data.message.trim()?e.data.message:(0,s.__)("Hello! I am ready to help with your WordPress tasks.","clawpress"),o="string"==typeof e?.data?.subtitle&&e.data.subtitle.trim()?e.data.subtitle:"",c="string"==typeof e?.data?.emoji&&e.data.emoji.trim()?e.data.emoji:"👋",i=g(e);return(0,t.jsx)("div",{className:"clawpress-card clawpress-card-welcome",children:(0,t.jsxs)("div",{className:"clawpress-card-body",children:[(0,t.jsx)("div",{className:"clawpress-card-section-title",children:(0,t.jsxs)("div",{className:"clawpress-card-welcome-title-line",children:[(0,t.jsx)("span",{className:"clawpress-card-emoji","aria-hidden":"true",children:c}),(0,t.jsx)("div",{className:"clawpress-card-title",children:n})]})}),o?(0,t.jsx)("div",{className:"clawpress-card-section-subtitle",children:(0,t.jsx)("div",{className:"clawpress-card-subtitle",children:o})}):null,(0,t.jsx)("div",{className:"clawpress-card-section-content",children:(0,t.jsx)("div",{className:"clawpress-card-text",children:l})}),i.length>0?(0,t.jsx)("div",{className:"clawpress-card-section-buttons",children:(0,t.jsx)("div",{className:"clawpress-card-actions",children:i.map(e=>(0,t.jsx)("button",{type:"button",className:"button button-secondary button-small",onClick:()=>r?.(e),disabled:a,children:e.label},e.id))})}):null]})})},y="__clawpress_custom_model__",f=(e,s)=>e.filter(e=>"send_prompt"===e?.type&&"string"==typeof e.prompt&&e.prompt.startsWith(s)).map(e=>({...e,value:e.prompt.slice(s.length).trim()})).filter(e=>e.value.length>0),_=e=>"string"!=typeof e?"":e.replace(/\r\n/g,"\n").replace(//gi,"\n"),b=({card:r,onSendAction:a,isBusy:n=!1})=>{const l=r?.data&&"object"==typeof r.data&&!Array.isArray(r.data)?r.data:{},o="string"==typeof l.title&&l.title.trim()?l.title:(0,s.__)("Setup Wizard","clawpress"),c="string"==typeof l.emoji&&l.emoji.trim()?l.emoji:"🧙",i="string"==typeof l.message&&l.message.trim()?_(l.message):(0,s.__)("Follow these steps to finish setup.","clawpress"),d="string"==typeof l.detail&&l.detail.trim()?l.detail:"",u="string"==typeof l.error&&l.error.trim()?l.error:"",p=_(d),m=_(u),h="string"==typeof l.step?l.step.trim():"",w="string"==typeof l.step_label&&l.step_label.trim()?l.step_label:h,b=Number.isFinite(Number(l.step_index))?Number(l.step_index):null,x=Number.isFinite(Number(l.step_total))?Number(l.step_total):null,j=Array.isArray(l.steps)?l.steps:[],N=g(r),v=(0,e.useMemo)(()=>f(N,"/setup provider "),[N]),k=(0,e.useMemo)(()=>f(N,"/setup model "),[N]),S="string"==typeof l.selected_model&&l.selected_model.trim()?l.selected_model.trim():"",A=(0,e.useMemo)(()=>"model"!==h?k:[...k,{id:y,label:(0,s.__)("Use Custom Model ID","clawpress"),type:"send_prompt",prompt:""}],[k,h]),C=(0,e.useMemo)(()=>{const e=new Set([...v.map(e=>e.id),...k.map(e=>e.id)]);return N.filter(s=>!e.has(s.id))},[N,v,k]),E=(0,e.useMemo)(()=>{const e=C.some(e=>"send_prompt"===e?.type&&"string"==typeof e.prompt&&"/setup back"===e.prompt.trim());return"provider"===h||e?C:[{id:"wizard-back-fallback",label:(0,s.__)("Back","clawpress"),type:"send_prompt",prompt:"/setup back"},...C]},[C,h]),[R,T]=(0,e.useState)(v[0]?.id||""),[$,D]=(0,e.useState)(A[0]?.id||""),[P,M]=(0,e.useState)("");(0,e.useEffect)(()=>{const e=v.map(e=>e.id);0!==e.length?e.includes(R)||T(e[0]):T("")},[v,R]),(0,e.useEffect)(()=>{const e=A.map(e=>e.id);0!==e.length?e.includes($)||D(e[0]):D("")},[A,$]),(0,e.useEffect)(()=>{if("model"!==h||!S)return;const e=k.find(e=>e.value===S);e?D(e.id):(D(y),M(S))},[k,S,h]);const L=v.find(e=>e.id===R)||v[0]||null,I=k.find(e=>e.id===$)||k[0]||null,O="model"===h&&$===y,B=P.trim(),q="string"==typeof l.settings_url&&l.settings_url.trim()?l.settings_url.trim():"",F=(0,e.useMemo)(()=>{if(!q||"undefined"==typeof window)return!1;try{const e=new URL(window.location.href),s=new URL(q,window.location.origin);return e.pathname===s.pathname&&e.search===s.search}catch{return!1}},[q]),U=b&&x?(0,s.sprintf)(/* translators: 1: current step number, 2: total step count, 3: step label. */ /* translators: 1: current step number, 2: total step count, 3: step label. */ -(0,s.__)("Step %1$d OF %2$d : %3$s","clawpress"),b,x,w||""):"",W="workspace"===h&&"string"==typeof l.workspace_path&&l.workspace_path.trim()?(e=>{if("string"!=typeof e)return"";const s=e.trim().replace(/\\/g,"/");if(!s)return"";const t=s.indexOf("/wp-content");return-1===t?s:s.slice(t)})(l.workspace_path):"",H="workspace"!==h?"":"string"==typeof l.workspace_exists&&l.workspace_exists.trim()?l.workspace_exists.trim():"string"==typeof l.workspace_exists_line&&l.workspace_exists_line.trim()?l.workspace_exists_line.replace(/^Exists\s*:\s*/i,"").trim():"",J="provider"===h&&v.length>1,z="model"===h&&A.length>0,G="provider"===h&&1===v.length,K="model"===h&&!z&&1===k.length,X=G||K||E.length>0,V=J||z||X;let Z="";Z=O?B?`/setup model ${B}`:"":I?.prompt||"";const Q="provider"===h?L?.prompt||"":Z,Y=n||"provider"===h&&!L||"model"===h&&(O?0===B.length:!I);return(0,t.jsx)("div",{className:"clawpress-card clawpress-card-setup",children:(0,t.jsxs)("div",{className:"clawpress-card-body",children:[(0,t.jsx)("div",{className:"clawpress-card-section-title",children:(0,t.jsxs)("div",{className:"clawpress-card-setup-title-line",children:[(0,t.jsx)("span",{className:"clawpress-card-setup-emoji","aria-hidden":"true",children:c}),(0,t.jsx)("div",{className:"clawpress-card-title",children:o})]})}),U||j.length>0?(0,t.jsxs)("div",{className:"clawpress-card-section-subtitle",children:[U?(0,t.jsx)("div",{className:"clawpress-card-subtitle clawpress-card-setup-step-line",children:U}):null,j.length>0?(0,t.jsx)("div",{className:"clawpress-card-setup-progress","aria-hidden":"true",children:j.map((e,s)=>{const r="string"==typeof e?.status?e.status:"pending",a=["pending","done","current","completed"].includes(r)?r:"pending";return(0,t.jsxs)("div",{className:`clawpress-card-setup-progress-item clawpress-card-setup-progress-${a}`,children:[(0,t.jsx)("span",{className:"clawpress-card-setup-progress-dot",children:s+1}),sT(e.target.value),disabled:n,children:v.map(e=>(0,t.jsx)("option",{value:e.id,children:e.label},e.id))}):null,z?(0,t.jsx)("select",{className:"clawpress-card-setup-select",value:$,onChange:e=>D(e.target.value),disabled:n,children:A.map(e=>(0,t.jsx)("option",{value:e.id,children:e.label},e.id))}):null,O?(0,t.jsx)("input",{type:"text",className:"clawpress-card-setup-input",value:P,onChange:e=>M(e.target.value),placeholder:(0,s.__)("Enter custom model ID","clawpress"),disabled:n}):null,(0,t.jsx)("button",{type:"button",className:"button button-primary button-small",disabled:Y,onClick:()=>a?.(Q),children:"provider"===h?(0,s.__)("Use Selected Provider","clawpress"):(0,s.__)("Use Selected Model","clawpress")})]}):null,X?(0,t.jsxs)("div",{className:"clawpress-card-actions",children:[G?(0,t.jsx)("button",{type:"button",className:"button button-primary button-small",onClick:()=>a?.(v[0]),disabled:n,children:v[0].label}):null,K?(0,t.jsx)("button",{type:"button",className:"button button-primary button-small",onClick:()=>a?.(k[0]),disabled:n,children:k[0].label}):null,E.map(e=>(0,t.jsx)("button",{type:"button",className:"button button-secondary button-small",onClick:()=>a?.(e),disabled:n,children:e.label},e.id))]}):null]}):null]})})},x=({card:e})=>{const r="string"==typeof e?.data?.title&&e.data.title.trim()?e.data.title:(0,s.__)("Request Error","clawpress"),a="string"==typeof e?.data?.message&&e.data.message.trim()?e.data.message:(0,s.__)("An unknown error occurred.","clawpress"),n="string"==typeof e?.data?.subtitle&&e.data.subtitle.trim()?e.data.subtitle:"";return(0,t.jsx)("div",{className:"clawpress-card clawpress-card-error",role:"alert","aria-live":"polite",children:(0,t.jsxs)("div",{className:"clawpress-card-body",children:[(0,t.jsx)("div",{className:"clawpress-card-section-title",children:(0,t.jsx)("div",{className:"clawpress-card-title",children:r})}),n?(0,t.jsx)("div",{className:"clawpress-card-section-subtitle",children:(0,t.jsx)("div",{className:"clawpress-card-subtitle",children:n})}):null,(0,t.jsx)("div",{className:"clawpress-card-section-content",children:(0,t.jsx)("div",{className:"clawpress-card-text",children:a})})]})})},j=({card:e,onSendAction:r,isBusy:a=!1})=>{const n="string"==typeof e?.data?.title&&e.data.title.trim()?e.data.title:(0,s.__)("User Confirmation Required","clawpress"),l="string"==typeof e?.data?.subtitle&&e.data.subtitle.trim()?e.data.subtitle:(0,s.__)("Destructive action pending","clawpress"),o="string"==typeof e?.data?.message&&e.data.message.trim()?e.data.message:(0,s.__)("Please confirm or decline this action.","clawpress"),c=g(e);return(0,t.jsx)("div",{className:"clawpress-card clawpress-card-user-confirmation",role:"alert","aria-live":"polite",children:(0,t.jsxs)("div",{className:"clawpress-card-body",children:[(0,t.jsx)("div",{className:"clawpress-card-section-title",children:(0,t.jsx)("div",{className:"clawpress-card-title",children:n})}),l?(0,t.jsx)("div",{className:"clawpress-card-section-subtitle",children:(0,t.jsx)("div",{className:"clawpress-card-subtitle",children:l})}):null,(0,t.jsx)("div",{className:"clawpress-card-section-content",children:(0,t.jsx)("div",{className:"clawpress-card-text",children:o})}),c.length>0?(0,t.jsx)("div",{className:"clawpress-card-section-buttons",children:(0,t.jsx)("div",{className:"clawpress-card-actions",children:c.map((e,s)=>(0,t.jsx)("button",{type:"button",className:"button button-small "+(0===s?"button-primary":"button-secondary"),onClick:()=>r?.(e),disabled:a,children:e.label},e.id))})}):null]})})},N=({card:e,fallbackText:r,onSendAction:a,isBusy:n=!1})=>{if(!e||"object"!=typeof e)return(0,t.jsx)("div",{className:"clawpress-msg-content",children:r||""});switch(e.type){case"welcome":return(0,t.jsx)(w,{card:e,onSendAction:a,isBusy:n});case"setup":return(0,t.jsx)(b,{card:e,onSendAction:a,isBusy:n});case"error":return(0,t.jsx)(x,{card:e});case"user_confirmation":return(0,t.jsx)(j,{card:e,onSendAction:a,isBusy:n})}const l="string"==typeof e?.data?.title&&e.data.title.trim()?e.data.title:(0,s.__)("Card","clawpress"),o="string"==typeof e?.data?.message&&e.data.message.trim()?e.data.message:r||"",c="string"==typeof e?.data?.subtitle&&e.data.subtitle.trim()?e.data.subtitle:"",i=g(e);return o||0!==i.length?(0,t.jsx)("div",{className:"clawpress-card clawpress-card-generic",children:(0,t.jsxs)("div",{className:"clawpress-card-body",children:[l?(0,t.jsx)("div",{className:"clawpress-card-section-title",children:(0,t.jsx)("div",{className:"clawpress-card-title",children:l})}):null,c?(0,t.jsx)("div",{className:"clawpress-card-section-subtitle",children:(0,t.jsx)("div",{className:"clawpress-card-subtitle",children:c})}):null,o?(0,t.jsx)("div",{className:"clawpress-card-section-content",children:(0,t.jsx)("div",{className:"clawpress-card-text",children:o})}):null,i.length>0?(0,t.jsx)("div",{className:"clawpress-card-section-buttons",children:(0,t.jsx)("div",{className:"clawpress-card-actions",children:i.map(e=>(0,t.jsx)("button",{type:"button",className:"button button-secondary button-small",onClick:()=>a?.(e),disabled:n,children:e.label},e.id))})}):null]})}):(0,t.jsx)("div",{className:"clawpress-msg-content",children:r||""})},v=e=>"string"==typeof e&&e?e.split(/(`[^`\n]+`|\*\*[^*\n]+?\*\*)/g).map((e,s)=>{const r=e.match(/^\*\*([^*\n]+?)\*\*$/);if(r)return(0,t.jsx)("strong",{children:r[1]},`strong-${s}`);const a=e.match(/^`([^`\n]+)`$/);return a?(0,t.jsx)("code",{children:a[1]},`code-${s}`):e}):e||"",k=({messages:r,streaming:a,currentStreamText:n,waitingForResponse:l,toolDialogs:o,onRunToolDialog:c,onCancelToolDialog:i,onSendCardAction:d})=>{const u=(0,e.useRef)(null);(0,e.useEffect)(()=>{const e=u.current;e&&(e.scrollTop=e.scrollHeight)},[r,o,a,n,l]);const p=o.filter(e=>"serial"===m(e.function?.name||"").concurrency&&"done"!==e.status&&"cancelled"!==e.status).sort((e,s)=>e.createdAt-s.createdAt),g=p[0]?.id||o[o.length-1]?.id||null,w=r.length-1,y=[...r.map((e,s)=>({type:"message",createdAt:e.createdAt??0,messageIndex:s,data:e})),...o.map(e=>({type:"tool",createdAt:e.createdAt??0,data:e}))].sort((e,s)=>{const t=e.createdAt-s.createdAt;return 0!==t?t:"message"===e.type&&"message"===s.type?(e.messageIndex??0)-(s.messageIndex??0):"message"===e.type?-1:"message"===s.type?1:0});return(0,t.jsxs)("div",{className:"clawpress-messages",ref:u,children:[y.map(e=>"message"===e.type?(()=>{const r="system"===e.data.role,n="assistant"===e.data.role,o=e.data.card&&"object"==typeof e.data.card,c=o&&e.messageIndex===w,i=e.data.content||"",u=/(\.\.\.|…)\s*$/.test(i),p=r&&u,m=p?i.replace(/\s*(\.\.\.|…)\s*$/,""):i;return(0,t.jsxs)("div",{className:`clawpress-msg clawpress-${e.data.role}`,children:[r||n?(0,t.jsx)("div",{className:"clawpress-msg-label",children:r?(0,s.__)("System","clawpress"):(0,s.__)("AGENT","clawpress")}):null,o?(0,t.jsx)(N,{card:e.data.card,fallbackText:m,onSendAction:d,isBusy:a||l||!c}):(0,t.jsx)("div",{className:"clawpress-msg-content"+(p?" clawpress-thinking":""),children:v(m)})]},e.data.id||e.data.content)})():(0,t.jsx)(h,{toolDialog:e.data,isOpen:e.data.id===g,onRunTool:c,onCancel:i},e.data.id)),l&&!n?(0,t.jsx)("div",{className:"clawpress-msg clawpress-system",children:(0,t.jsx)("div",{className:"clawpress-msg-content clawpress-thinking",children:(0,s.__)("Thinking","clawpress")})}):null,a&&n?(0,t.jsxs)("div",{className:"clawpress-msg clawpress-assistant",children:[(0,t.jsx)("div",{className:"clawpress-msg-label",children:(0,s.__)("AGENT","clawpress")}),(0,t.jsx)("div",{className:"clawpress-msg-content",children:v(n||"...")})]}):null]})},S=({onToggle:e})=>(0,t.jsx)("button",{className:"button button-primary clawpress-toggle",onClick:e,type:"button",children:(0,s.__)("ClawPress","clawpress")}),A=async({url:e,method:t="GET",nonce:r,body:a,signal:n})=>{const l=await fetch(e,{method:t,credentials:"same-origin",headers:{"Content-Type":"application/json","X-WP-Nonce":r},body:a?JSON.stringify(a):void 0,signal:n}),o=await l.text();let c={};if(o)try{c=JSON.parse(o)}catch{c={message:o}}if(!l.ok){const e=c?.message||c?.error||(0,s.sprintf)(/* translators: %d: HTTP status code */ /* translators: %d: HTTP status code */ -(0,s.__)("Request failed (%d)","clawpress"),l.status);throw new Error(e)}return c},C=({mockEnabled:e,mockScenario:t,mockDelay:r,restBase:a,streamNonce:n,nonce:l,onEvent:o,onDone:c,onError:i})=>e?(({mockScenario:e,mockDelay:t,onEvent:r,onDone:a,onError:n})=>({stream:l=>(({prompt:e,mode:t="normal",delayMode:r="normal",onEvent:a,onDone:n,onError:l})=>{const o=[];let c=!1;const i="slow"===r?3e3:0,d=(e,s)=>{const t=setTimeout(()=>{c||e()},s+i);o.push(t)},u=(e,s)=>{c||a({type:e,payload:s})},p=()=>{c=!0,o.forEach(clearTimeout),n?.({aborted:!0})};if("infinite"===r)return{stop:p};if("error"===t)return d(()=>l?.({error:(0,s.__)("Mock error: something went wrong.","clawpress")}),300),d(()=>n?.({aborted:!1}),600),{stop:p};"tool"!==t&&"tool_error"!==t||(d(()=>u("tool_call",{function:{name:"update_posts_find_replace",arguments:"{}"}}),200),d(()=>u("tool_plan",{function:{name:"update_posts_find_replace",arguments:{search:"tool_error"===t?(0,s.sprintf)(/* translators: %s: mock text prefixed with ERROR to trigger an error path */ /* translators: %s: mock text prefixed with ERROR to trigger an error path */ -(0,s.__)("ERROR: %s","clawpress"),(0,s.__)("Old Phrase","clawpress")):(0,s.__)("Old Phrase","clawpress"),replace:"tool_error"===t?(0,s.sprintf)(/* translators: %s: mock text prefixed with ERROR to trigger an error path */ /* translators: %s: mock text prefixed with ERROR to trigger an error path */ -(0,s.__)("ERROR: %s","clawpress"),(0,s.__)("New Phrase","clawpress")):(0,s.__)("New Phrase","clawpress"),post_status:"tool_error"===t?"draft":"publish",dry_run:!0}}}),700));const m=(0,s.__)("Here is a longer mock response to help you test streaming behavior.","clawpress")+(0,s.__)("It should keep streaming long enough for you to press Stop and see the UI react.","clawpress")+(0,s.__)("We can include multiple sentences, line breaks, and a bit of variety.","clawpress")+(0,s.__)("Chunk one: The quick brown fox jumps over the lazy dog.","clawpress")+(0,s.__)("Chunk two: Sphinx of black quartz, judge my vow.","clawpress")+(0,s.__)("Chunk three: Pack my box with five dozen liquor jugs.","clawpress")+(0,s.__)("Final chunk: This should be enough to test canceling a long stream.","clawpress");let h=(0,s.sprintf)(/* translators: %s: user prompt */ /* translators: %s: user prompt */ -(0,s.__)('Here is a mock response for: "%s"',"clawpress"),e);"tool"===t||"tool_error"===t?h=(0,s.__)("I found a tool that can update posts. Here is the proposed change.","clawpress"):"long"===t&&(h=m);const g=h.split("");let w=150;return g.forEach(e=>{d(()=>u("delta",{text:e}),w),w+=12}),d(()=>n?.({aborted:!1}),w+200),{stop:p}})({prompt:l,mode:e,delayMode:t,onEvent:({type:e,payload:s})=>r(e,s),onDone:()=>a?.({aborted:!1}),onError:n}),runTool:(e,r)=>(async(e,t,{mockDelay:r="normal"}={})=>{if("infinite"===r)await new Promise(()=>{});else{const e="slow"===r?3e3:300;await new Promise(s=>setTimeout(s,e))}if(("string"==typeof t?.search?t.search:"").includes("ERROR"))throw new Error((0,s.__)("Mock tool error: execution failed.","clawpress"));return{step:{data:{result:{dry_run:!0,total:2,changed:[{id:123,title:(0,s.__)("Hello World","clawpress"),count:1},{id:456,title:(0,s.__)("Sample Post","clawpress"),count:2}],tool:e,args:t}}}}})(e,r,{mockDelay:t}),getHistory:async()=>({items:[]}),getStatus:async()=>({mode:"offline",provider:{id:null,configured:!1},model:{id:null,configured:!1},setup:{completed:!1},memory:{enabled:!1},agent_user:{id:null,configured:!1},suggestions:["/help","/clear","/status","/setup resume","/memory list","/site info","/tools list"]}),getPanelState:async()=>({open:!1,width:420,last_history_id:""}),setPanelState:async e=>({open:Boolean(e?.open),width:Number(e?.width)||420,last_history_id:"string"==typeof e?.last_history_id?e.last_history_id:""})}))({mockScenario:t,mockDelay:r,onEvent:o,onDone:c,onError:i}):(({restBase:e,nonce:t,onEvent:r,onDone:a,onError:n})=>{const l=e=>{if(!e||"object"!=typeof e)return null;const s="string"==typeof e.type?e.type.trim():"";return s?{type:s,data:e.data&&"object"==typeof e.data&&!Array.isArray(e.data)?e.data:{}}:null};return{stream:o=>{const c=new AbortController;return(async()=>{try{const n=await(i=o,d=c.signal,A({url:`${e}/chat/message`,method:"POST",nonce:t,body:{message:i},signal:d}));n?.meta?.command?.effects&&!0===n.meta.command.effects.clear_history&&r("history_reset",{}),Array.isArray(n?.meta?.suggestions)&&r("suggestions",{items:n.meta.suggestions});const u=(e=>{if(!e||"object"!=typeof e)return null;const s=e=>{const s=Number(e);return!Number.isFinite(s)||s<0?null:Math.round(s)},t=e=>{if(null==e)return null;const s=Number(e);return Number.isFinite(s)?Math.max(0,Math.min(100,Math.round(s))):null},r=s(e.prompt_tokens)??0,a=s(e.completion_tokens)??0,n=s(e.total_tokens)??0,l=s(e.used_tokens)??(r>0?r:n),o=s(e.context_window_tokens)??null;return 0===r&&0===a&&0===n&&0===l&&null===o?null:{promptTokens:r,completionTokens:a,totalTokens:n,usedTokens:l,contextWindowTokens:o,percentUsed:t(e.percent_used),percentLeft:t(e.percent_left),windowIsEstimated:"boolean"==typeof e.window_is_estimated?e.window_is_estimated:null}})(n?.meta?.context);u&&r("context_usage",{context:u});const p=Array.isArray(n?.meta?.tool_calls)?n.meta.tool_calls.map(e=>(e=>{if(!e||"object"!=typeof e)return null;const s="string"==typeof e.name?e.name.trim():"";if(!s)return null;const t="string"==typeof e.ability?e.ability.trim():"",r=e.args&&"object"==typeof e.args&&!Array.isArray(e.args)?e.args:{},a=(e=>{const s="string"==typeof e?e.trim().toLowerCase():"";return"success"===s||"error"===s||"requires_confirmation"===s?s:"success"})(e.status);return{name:s,ability:t||null,args:r,status:a,message:("string"==typeof e.message&&e.message.trim()?e.message.trim():"")||null,round:Number.isFinite(Number(e.round))?Math.max(1,Math.round(Number(e.round))):1,sequence:Number.isFinite(Number(e.sequence))?Math.max(1,Math.round(Number(e.sequence))):1,requiresConfirmation:"boolean"==typeof e.requires_confirmation?e.requires_confirmation:"requires_confirmation"===a}})(e)).filter(Boolean):[];p.forEach((e,s)=>{r("tool_call",{call:e,index:s+1,total:p.length})});const m=n?.meta?.error&&"object"==typeof n.meta.error?n.meta.error:null,h=n?.meta?.card&&"object"==typeof n.meta.card?l(n.meta.card):null;if(m){const e="string"==typeof m.message&&m.message.trim()?m.message.trim():(0,s.__)("Chat request failed.","clawpress");return r("error",{error:e,type:"string"==typeof m.type&&m.type.trim()?m.type.trim():"provider",card:h}),void a?.({aborted:!1})}const g="string"==typeof n?.reply?n.reply.trim():"",w=Boolean(n?.meta?.command?.name),y=l(n?.meta?.card);y?r("response_card",{card:y,text:g,role:w?"system":"assistant"}):g&&r("response_message",{text:g,role:w?"system":"assistant"}),a?.({aborted:!1})}catch(e){if("AbortError"===e?.name)return void a?.({aborted:!0});n?.({error:e?.message||(0,s.__)("Chat request failed.","clawpress"),type:"request"}),a?.({aborted:!1})}var i,d})(),{stop:()=>c.abort()}},runTool:async()=>{throw new Error((0,s.__)("Tool execution is not available in chat mode.","clawpress"))},getHistory:()=>A({url:`${e}/chat/history`,method:"GET",nonce:t}),getStatus:()=>A({url:`${e}/status`,method:"GET",nonce:t}),getPanelState:()=>A({url:`${e}/panel/state`,method:"GET",nonce:t}),setPanelState:s=>A({url:`${e}/panel/state`,method:"POST",nonce:t,body:s})}})({restBase:a,streamNonce:n,nonce:l,onEvent:o,onDone:c,onError:i}),E=({mockEnabled:e,mockScenario:r,mockDelay:a,onSelectScenario:n,onSelectDelay:l,onRunScenario:o,themeMode:c})=>{if(!e)return null;const i=[{key:"normal",label:(0,s.__)("Normal","clawpress")},{key:"long",label:(0,s.__)("Long","clawpress")},{key:"tool",label:(0,s.__)("Tool","clawpress")},{key:"tool_error",label:(0,s.__)("Tool Error","clawpress")},{key:"error",label:(0,s.__)("Error","clawpress")}],d=[{key:"normal",label:(0,s.__)("Normal","clawpress")},{key:"slow",label:(0,s.__)("Slow (3s)","clawpress")},{key:"infinite",label:(0,s.__)("Infinite","clawpress")}];return(0,t.jsxs)("div",{className:"clawpress-mock-panel","data-theme":c,children:[(0,t.jsx)("div",{className:"clawpress-mock-badge",children:(0,s.__)("Mock","clawpress")}),(0,t.jsxs)("fieldset",{className:"clawpress-mock-fieldset",children:[(0,t.jsx)("legend",{children:(0,s.__)("Scenario","clawpress")}),(0,t.jsx)("div",{className:"clawpress-mock-buttons",children:i.map(e=>(0,t.jsx)("button",{className:"button "+(r===e.key?"button-primary":""),type:"button",onClick:()=>n(e.key),children:e.label},e.key))})]}),(0,t.jsxs)("fieldset",{className:"clawpress-mock-fieldset",children:[(0,t.jsx)("legend",{children:(0,s.__)("Response delay","clawpress")}),(0,t.jsx)("div",{className:"clawpress-mock-buttons",children:d.map(e=>(0,t.jsx)("button",{className:"button "+(a===e.key?"button-primary":""),type:"button",onClick:()=>l(e.key),children:e.label},e.key))})]}),(0,t.jsx)("button",{className:"button",type:"button",onClick:o,children:(0,s.__)("Run","clawpress")})]})},R=()=>{const[r,n]=(0,e.useState)(JSON.parse(localStorage.getItem("clawpress_open")||"false")),[c,i]=(0,e.useState)(Number(localStorage.getItem("clawpress_width")||CLAWPRESS_PANEL.defaultWidth)),[d,u]=(0,e.useState)([]),[p,h]=(0,e.useState)(""),[g,w]=(0,e.useState)(()=>{const e=localStorage.getItem("clawpress_input_history");if(!e)return[];try{const s=JSON.parse(e);return Array.isArray(s)?s.filter(e=>"string"==typeof e):[]}catch{return[]}}),[y,f]=(0,e.useState)(-1),[_,b]=(0,e.useState)(""),[x,j]=(0,e.useState)(!1),[N,v]=(0,e.useState)(""),[A,R]=(0,e.useState)(!1),[T,$]=(0,e.useState)([]),[D,P]=(0,e.useState)([]),[M,L]=(0,e.useState)(!1),[I,O]=(0,e.useState)(null),[B,q]=(0,e.useState)(!0),[F,U]=(0,e.useState)([]),[W,H]=(0,e.useState)(null),[J,z]=(0,e.useState)(!1),G=Boolean(CLAWPRESS_PANEL.mockEnabled),[K,X]=(0,e.useState)(localStorage.getItem("clawpress_theme")||"light"),[V,Z]=(0,e.useState)("normal"),[Q,Y]=(0,e.useState)("normal"),[ee,se]=(0,e.useState)(!1),te=(0,e.useRef)(null),re=(0,e.useRef)(0),ae=(0,e.useRef)([]),ne=(0,e.useRef)(!1),le=(0,e.useRef)(null),oe=(0,e.useRef)(null),ce=(0,e.useRef)(!1),ie=(0,e.useRef)(null),de=(0,e.useRef)(null),ue=(0,e.useRef)(null),pe=(0,e.useRef)(null),me=(0,e.useRef)(N),he=(0,e.useRef)(T);(0,e.useEffect)(()=>{me.current=N},[N]),(0,e.useEffect)(()=>{},[]),(0,e.useEffect)(()=>{he.current=T},[T]);const ge=(0,e.useRef)(!1),we=(0,e.useRef)(0),ye=(0,e.useRef)(c),fe=e=>{if(!e||"object"!=typeof e)return null;const s="string"==typeof e.type?e.type.trim():"";return s?{type:s,data:e.data&&"object"==typeof e.data&&!Array.isArray(e.data)?e.data:{}}:null},_e=(e,s,t=null)=>u(r=>[...r,{id:`msg-${Date.now()}-${Math.random()}`,role:e,content:s,card:fe(t),createdAt:++re.current}]),be=(e,t=null,r=null)=>{if(!e||"object"!=typeof e)return"";const a="string"==typeof e.name?e.name.trim():"";if(!a)return"";const n="string"==typeof e.status?e.status.trim().toLowerCase():"success",l="string"==typeof e.message?e.message.trim():"";let o=(0,s.__)("success","clawpress");"error"===n?o=(0,s.__)("error","clawpress"):"requires_confirmation"===n&&(o=(0,s.__)("confirmation required","clawpress"));let c=(0,s.sprintf)(/* translators: 1: tool name, 2: tool call status */ /* translators: 1: tool name, 2: tool call status */ -(0,s.__)("Tool call `%1$s` (%2$s)","clawpress"),a,o);return Number.isFinite(Number(t))&&Number.isFinite(Number(r))&&(c=(0,s.sprintf)(/* translators: 1: tool call position, 2: total tool calls, 3: tool summary text */ /* translators: 1: tool call position, 2: total tool calls, 3: tool summary text */ -(0,s.__)("[%1$d/%2$d] %3$s","clawpress"),Math.max(1,Math.round(Number(t))),Math.max(1,Math.round(Number(r))),c)),l?`${c}\n${l}`:c},xe=e=>{if(!Array.isArray(e))return[];const s=new Set;return e.map(e=>"string"==typeof e?e.trim():"").filter(e=>e.length>0).filter(e=>!s.has(e)&&(s.add(e),!0)).slice(0,8)},je=(0,e.useRef)(null),Ne=()=>{const e=je.current;e&&(u(s=>s.filter(s=>s.id!==e)),je.current=null)};(0,e.useEffect)(()=>localStorage.setItem("clawpress_open",JSON.stringify(r)),[r]),(0,e.useEffect)(()=>localStorage.setItem("clawpress_width",String(c)),[c]),(0,e.useEffect)(()=>{document.body.classList.toggle("clawpress-panel-open",r),document.body.style.setProperty("--clawpress-panel-width",`${c}px`);const e=document.querySelector("#wp-admin-bar-clawpress-toggle > a");e&&e.setAttribute("aria-expanded",r?"true":"false")},[r,c]),(0,e.useEffect)(()=>{localStorage.setItem("clawpress_theme",K)},[K]),(0,e.useEffect)(()=>{localStorage.setItem("clawpress_input_history",JSON.stringify(g))},[g]),(0,e.useEffect)(()=>{const e=e=>{if(!e.metaKey&&!e.ctrlKey||e.altKey||"k"!==e.key.toLowerCase())return;const s=e.target?.ownerDocument?.activeElement,t=s?.tagName?.toLowerCase();"input"===t||"textarea"===t||"select"===t||s?.isContentEditable||(e.preventDefault(),n(e=>!e))};return window.addEventListener("keydown",e),()=>window.removeEventListener("keydown",e)},[]),(0,e.useEffect)(()=>{const e=e=>{if(!ge.current)return;const s=we.current-e.clientX,t=Math.max(320,ye.current+s);i(t)},s=()=>{ge.current&&(ge.current=!1,document.body.classList.remove("clawpress-resizing"))};return window.addEventListener("mousemove",e),window.addEventListener("mouseup",s),()=>{window.removeEventListener("mousemove",e),window.removeEventListener("mouseup",s)}},[]);const ve=(e=null)=>{const s="string"==typeof e?e.trim():p.trim();s&&(h(""),w(e=>{const t=[...e,s],r=CLAWPRESS_PANEL.historyLimit??20;return t.length>r?t.slice(t.length-r):t}),f(-1),b(""),_e("user",s),R(!0),Te(s))},ke=e=>m(e).concurrency||"parallel",Se=(e,s=[])=>{const t=e?.name||"",r="serial"===ke(t)&&s.some(e=>e.function?.name===t&&"done"!==e.status&&"error"!==e.status&&"cancelled"!==e.status);return{id:`tool-${Date.now()}-${Math.random()}`,function:e,args:o(e),status:r?"blocked":"idle",result:null,error:null,diff:null,createdAt:++re.current}},Ae=()=>{if(ne.current)return;const e=ae.current;if(!e.length)return;const{type:t,payload:r}=e.shift();"assistant_message"===t&&r?.content?(e=>{if(!e)return;ne.current=!0,v(""),me.current="";let s=0;const t=()=>{if(s>=e.length)return _e("assistant",e),v(""),me.current="",ne.current=!1,le.current=null,void Ae();const r=e.slice(s,s+2);s+=2,v(e=>{const s=e+r;return me.current=s,s}),le.current=setTimeout(t,30)};t()})(r.content):(((e,t)=>{switch(Ne(),e){case"delta":if(t.text){const e=`${me.current||""}${t.text}`;me.current=e,v(e)}break;case"response_message":t?.text&&_e("system"===t?.role?"system":"assistant",t.text);break;case"response_card":t?.card&&_e("system"===t?.role?"system":"assistant","string"==typeof t?.text?t.text:"",t.card);break;case"history_reset":u([]),P([]),$([]),H(null),re.current=0;break;case"suggestions":{const e=xe(t?.items);U(e);break}case"context_usage":t?.context&&H(t.context);break;case"tool_call":if(t?.call&&"object"==typeof t.call){const e=be(t.call,t?.index,t?.total);e&&_e("system",e);break}M||((e=>{const s=`status-${Date.now()}-${Math.random()}`;je.current=s,u(t=>[...t,{id:s,role:"system",content:e,createdAt:++re.current}])})((0,s.__)("Preparing tool plan…","clawpress")),L(!0));break;case"tool_plan":t?.function&&"object"==typeof t.function&&$(e=>[...e,t]),L(!1);break;case"error":_e("system",t?.error||(0,s.__)("Stream error.","clawpress"),t?.card||((e,t="")=>{const r={timeout:(0,s.__)("Request timed out","clawpress"),request:(0,s.__)("Network or API request error","clawpress"),provider:(0,s.__)("Provider error","clawpress")},a="string"==typeof t&&r[t]?r[t]:(0,s.__)("Error","clawpress");return{type:"error",data:{title:(0,s.__)("Request Error","clawpress"),subtitle:a,message:"string"==typeof e&&e.trim()?e:(0,s.__)("Chat request failed.","clawpress")}}})(t?.error,t?.type));break;case"done":(()=>{j(!1),te.current=null,R(!1),Ne();const e=me.current;e&&e.trim()?(_e("assistant",e),v(""),me.current=""):(v(""),me.current="");const s=Array.isArray(he.current)?he.current:[];s.length>0&&P(e=>{const t=[...e];return s.forEach(e=>{e?.function&&"object"==typeof e.function&&t.push(Se(e.function,t))}),t}),$([]),Re(!0)})()}})(t,r),Ae())},Ce=(e,s)=>{R(!1),Ne(),ae.current.push({type:e,payload:s}),Ae()},Ee=()=>C({mockEnabled:G,mockScenario:V,mockDelay:Q,restBase:CLAWPRESS_PANEL.restBase,streamNonce:CLAWPRESS_PANEL.streamNonce,nonce:CLAWPRESS_PANEL.nonce,onEvent:Ce,onDone:()=>Ce("done",{}),onError:e=>Ce("error",e)}),Re=async(e=!1)=>{e||q(!0);try{const e=await(Ee().getStatus?.());e&&(O(e),U(s=>s.length>0?s:xe(e?.suggestions)))}catch{O(null)}finally{q(!1)}};ie.current=_e,de.current=e=>Array.isArray(e)?e.filter(e=>e&&"object"==typeof e).reduce((e,s,t)=>{const r="user"===s.role||"assistant"===s.role||"system"===s.role?s.role:"system",a="string"==typeof s.content?s.content:"",n=Number.isFinite(Number(s.createdAt))?Number(s.createdAt):t+1,l="string"==typeof s.id&&s.id?s.id:`history-${n}-${t}`,o=fe(s.card),c=Array.isArray(s.tool_calls)?s.tool_calls.filter(e=>e&&"object"==typeof e):[];return c.forEach((s,t)=>{const r=be(s,t+1,c.length);r&&e.push({id:`${l}-tool-${t+1}`,role:"system",content:r,card:null,createdAt:n})}),e.push({id:l,role:r,content:a,card:o,createdAt:n}),e},[]):[],ue.current=Ee,pe.current=Re,(0,e.useEffect)(()=>{let e=!0;return(async()=>{const t=ue.current?.();if(t){try{const s=await(t.getPanelState?.());if(!e)return;const a=(r=s)&&"object"==typeof r?{open:"boolean"==typeof r.open?r.open:null,width:Number.isFinite(Number(r.width))?Number(r.width):null,lastHistoryId:"string"==typeof r.last_history_id?r.last_history_id:"",welcomeCardSeen:"boolean"==typeof r.welcome_card_seen&&r.welcome_card_seen}:{open:null,width:null,lastHistoryId:"",welcomeCardSeen:!1};"boolean"==typeof a.open&&n(a.open),Number.isFinite(a.width)&&a.width>0&&i(a.width),ce.current=!0===a.welcomeCardSeen}catch{}var r;try{const s=await(t.getStatus?.());if(!e)return;O(s||null),U(e=>e.length>0?e:xe(s?.suggestions))}catch{if(!e)return;O(null)}finally{e&&q(!1)}try{const r=await(t.getHistory?.());if(!e)return;const a=de.current?.(r?.items||[])||[];if(0!==a.length||ce.current)u(a),re.current=a.reduce((e,s)=>Math.max(e,Number(s.createdAt)||0),0);else{const e=Date.now(),r={id:`welcome-${e}`,role:"assistant",content:"",card:{type:"welcome",data:{title:(0,s.__)("Welcome to ClawPress","clawpress"),message:(0,s.__)("Hello! I am ready to help with your WordPress tasks.","clawpress"),emoji:"👋",actions:[{id:"start-setup",label:(0,s.__)("Start Setup","clawpress"),prompt:"/setup start"}]}},createdAt:e};u([r]),re.current=e,t.setPanelState?.({welcome_card_seen:!0}).catch(()=>{})}}catch{if(!e)return;ie.current?.("system",(0,s.__)("Unable to load chat history.","clawpress"))}e&&z(!0)}})(),()=>{e=!1}},[]),(0,e.useEffect)(()=>{if(!r)return;pe.current?.(!0);const e=setInterval(()=>{pe.current?.(!0)},15e3);return()=>clearInterval(e)},[r]),(0,e.useEffect)(()=>{if(!J)return;const e=d[d.length-1],s={open:r,width:c,last_history_id:"string"==typeof e?.id?e.id:""};return oe.current&&clearTimeout(oe.current),oe.current=setTimeout(()=>{const e=ue.current?.();e?.setPanelState?.(s).catch(()=>{})},350),()=>{oe.current&&clearTimeout(oe.current)}},[J,r,c,d]);const Te=async e=>{j(!0),v(""),me.current="",$([]);const s=Ee();te.current=s.stream(e)},$e=(e,s)=>{P(t=>t.map(t=>t.id===e?{...t,...s}:t))};return(0,e.useEffect)(()=>{P(e=>{let s=!1;const t=e.map(t=>{const r=t.function?.name||"";if("serial"!==ke(r))return t;if("running"===t.status||"error"===t.status||"done"===t.status||"cancelled"===t.status)return t;const a=e.some(e=>e.function?.name===r&&e.createdAt{const e=document.querySelector("#wp-admin-bar-clawpress-toggle > a");if(!e)return void se(!0);se(!1);const s=e=>{e.preventDefault(),n(e=>!e)};return e.addEventListener("click",s),()=>e.removeEventListener("click",s)},[]),(0,t.jsxs)(e.Fragment,{children:[ee?(0,t.jsx)(S,{onToggle:()=>n(e=>!e)}):null,(0,t.jsxs)("div",{className:"clawpress-panel","data-theme":K,style:{width:`${c}px`},children:[(0,t.jsx)(a,{onClose:()=>n(!1),onToggleTheme:()=>X(e=>"light"===e?"dark":"light"),statusMode:I?.mode||"offline",statusLabel:(e=>{if(!e||"object"!=typeof e)return"";const s=e?.provider?.id,t=e?.model?.id;return s&&t?`${s} · ${t}`:s||""})(I),statusLoading:B}),(0,t.jsx)("button",{type:"button",className:"clawpress-drag-handle",onMouseDown:e=>{ge.current=!0,we.current=e.clientX,ye.current=c,document.body.classList.add("clawpress-resizing")},"aria-label":(0,s.__)("Resize panel","clawpress")}),(0,t.jsx)(k,{messages:d,streaming:x,currentStreamText:N,waitingForResponse:A,toolDialogs:D,onRunToolDialog:(e,t)=>{const r=D.find(s=>s.id===e);if(!r)return;const a=r.function?.name||"";if("serial"===ke(a)){const e=D.filter(e=>e.function?.name===a).sort((e,s)=>e.createdAt-s.createdAt),s=e.some(e=>"running"===e.status),n=e.some(e=>e.id!==r.id&&e.createdAt{const t=e.function?.name||"",r=e.args||o(e.function);$e(e.id,{status:"running",error:null});try{const s=await(async(e,s)=>{try{return await Ee().runTool(e,s)}catch(e){throw e}})(t,r),a=s.step?.data?.result??null,n=Array.isArray(a?.changed)?a.changed:null;$e(e.id,{status:"done",result:a,diff:n})}catch(t){$e(e.id,{status:"error",error:t?.message||(0,s.__)("Tool execution failed.","clawpress")})}})(t?{...r,args:t}:r)},onCancelToolDialog:e=>{$e(e,{status:"cancelled"})},onSendCardAction:e=>{if("string"==typeof e)return void ve(e);if(!e||"object"!=typeof e)return;if("open_url"===e.type){const t="string"==typeof e.url?e.url.trim():"";if(!t)return void _e("system",(0,s.__)("Invalid card action.","clawpress"));try{const e=new URL(t,window.location.origin);window.location.assign(e.toString())}catch{_e("system",(0,s.__)("Invalid card URL.","clawpress"))}return}if("run_tool"===e.type){const t="string"==typeof e.tool?e.tool.trim():"";if(!t)return void _e("system",(0,s.__)("Invalid card action.","clawpress"));const r=e.args&&"object"==typeof e.args&&!Array.isArray(e.args)?e.args:{};return void P(e=>{const s=[...e];return s.push(Se({name:t,arguments:r},s)),s})}const t="string"==typeof e.prompt?e.prompt.trim():"";t&&ve(t)}}),(0,t.jsx)(l,{input:p,onInputChange:e=>h(e.target.value),onSend:ve,suggestions:F,contextUsage:W,onSendSuggestion:e=>ve(e),onStop:()=>{te.current?.stop?.(),_e("system",(0,s.__)("Stream stopped.","clawpress"))},streaming:x,onHistoryUp:e=>{if(0===g.length)return null;if(-1===y){b(e);const s=g.length-1;return f(s),g[s]}const s=Math.max(0,y-1);return f(s),g[s]},onHistoryDown:()=>{if(-1===y)return null;const e=y+1;if(e>=g.length){f(-1);const e=_;return b(""),e}return f(e),g[e]}})]}),(0,t.jsx)(E,{mockEnabled:G,mockScenario:V,mockDelay:Q,onSelectScenario:Z,onSelectDelay:Y,onRunScenario:()=>{if(!G)return;h("");const e=(0,s.sprintf)(/* translators: %s: selected mock scenario */ /* translators: %s: selected mock scenario */ -(0,s.__)("Mock: %s","clawpress"),V);_e("user",e),Te(e)},themeMode:K})]})};let T=document.getElementById("clawpress-floating-panel-root");T||(T=document.createElement("div"),T.id="clawpress-floating-panel-root",document.body.appendChild(T)),(0,e.createRoot)(T).render((0,t.jsx)(R,{}))})(); \ No newline at end of file +(()=>{"use strict";const e=window.wp.element,t=window.wp.i18n,s=window.ReactJSXRuntime,r=e=>"online"===e?(0,t.__)("Online","clawpress"):"offline"===e?(0,t.__)("Offline","clawpress"):e,a=({onClose:e,onToggleTheme:a,statusMode:n,statusLabel:l,statusLoading:o})=>{const i=n||"offline";return(0,s.jsxs)("div",{className:"clawpress-header",children:[(0,s.jsxs)("div",{className:"clawpress-header-meta",children:[(0,s.jsx)("div",{className:"clawpress-title",children:(0,t.__)("ClawPress Agent","clawpress")}),(0,s.jsxs)("div",{className:`clawpress-status clawpress-status-${i}`,children:[(0,s.jsx)("span",{className:"clawpress-status-dot"}),(0,s.jsx)("span",{className:"clawpress-status-mode",children:o?(0,t.__)("Checking…","clawpress"):r(i)}),l?(0,s.jsx)("span",{className:"clawpress-status-label",children:l}):null]})]}),(0,s.jsx)("button",{className:"clawpress-theme-toggle",type:"button",onClick:a,"aria-label":(0,t.__)("Toggle theme","clawpress"),children:(0,s.jsx)("svg",{viewBox:"0 0 24 24","aria-hidden":"true",focusable:"false",children:(0,s.jsx)("path",{d:"M12 18q2.484 0 4.242-1.758t1.758-4.242-1.758-4.242-4.242-1.758q-1.219 0-2.484 0.563 1.547 0.703 2.508 2.18t0.961 3.258-0.961 3.258-2.508 2.18q1.266 0.563 2.484 0.563zM20.016 8.672l3.281 3.328-3.281 3.328v4.688h-4.688l-3.328 3.281-3.328-3.281h-4.688v-4.688l-3.281-3.328 3.281-3.328v-4.688h4.688l3.328-3.281 3.328 3.281h4.688v4.688z"})})}),(0,s.jsx)("button",{className:"clawpress-close",onClick:e,type:"button","aria-label":(0,t.__)("Close panel","clawpress"),children:(0,s.jsx)("svg",{viewBox:"0 0 24 24","aria-hidden":"true",focusable:"false",children:(0,s.jsx)("path",{d:"M18.984 6.422l-5.578 5.578 5.578 5.578-1.406 1.406-5.578-5.578-5.578 5.578-1.406-1.406 5.578-5.578-5.578-5.578 1.406-1.406 5.578 5.578 5.578-5.578z"})})})]})},n=e=>{const t=Number(e);if(!Number.isFinite(t)||t<=0)return"0";if(t<1e3)return String(Math.round(t));const s=[{threshold:1e9,suffix:"b"},{threshold:1e6,suffix:"m"},{threshold:1e3,suffix:"k"}];for(const e of s)if(t>=e.threshold){const s=t/e.threshold,r=s>=100?0:1;return`${s.toFixed(r).replace(/\.0$/,"")}${e.suffix}`}return String(Math.round(t))},l=({input:r,onInputChange:a,onSend:l,onStop:o,streaming:i,panelOpen:c,suggestions:u,contextUsage:d,onSendSuggestion:p,onHistoryUp:m,onHistoryDown:g})=>{const h=(0,e.useRef)(null),w=(0,e.useRef)(i),f=Number(d?.usedTokens),y=Number(d?.contextWindowTokens),_=Number(d?.percentUsed),b=Number.isFinite(f)&&f>=0?Math.round(f):0,x=Number.isFinite(y)&&y>0?Math.round(y):0;let N=null;Number.isFinite(_)&&_>=0?N=Math.max(0,Math.min(100,Math.round(_))):x>0&&(N=Math.max(0,Math.min(100,Math.round(b/x*100))));const v=null===N?null:Math.max(0,100-N),j=x>0&&null!==N&&b>=0,k=j&&null!==v?(0,t.sprintf)(/* translators: 1: percentage used, 2: percentage left */ /* translators: 1: percentage used, 2: percentage left */ +(0,t.__)("%1$d%% used (%2$d%% left)","clawpress"),N,v):"",S=j?(0,t.sprintf)(/* translators: 1: used tokens, 2: available context-window tokens */ /* translators: 1: used tokens, 2: available context-window tokens */ +(0,t.__)("%1$s / %2$s tokens used","clawpress"),n(b),n(x)):"";return(0,e.useEffect)(()=>{const e=w.current;if(w.current=i,!e||i||!c)return;const t=h.current;if(!t)return;t.focus();const s=t.value.length;t.setSelectionRange(s,s)},[i,c]),(0,s.jsxs)("div",{className:"clawpress-input",children:[Array.isArray(u)&&u.length>0?(0,s.jsxs)("div",{className:"clawpress-suggestions","aria-label":(0,t.__)("Suggestions","clawpress"),children:[(0,s.jsx)("div",{className:"clawpress-suggestions-label",children:(0,t.__)("Suggestions","clawpress")}),(0,s.jsx)("div",{className:"clawpress-suggestions-list",children:u.map(e=>(0,s.jsx)("button",{className:"clawpress-suggestion button button-secondary",onClick:()=>p?.(e),type:"button",disabled:i,children:e},e))})]}):null,(0,s.jsx)("textarea",{ref:h,value:r,onChange:a,onKeyDown:e=>{if("Enter"!==e.key||e.shiftKey||(e.preventDefault(),l()),"ArrowUp"===e.key){const t=h.current,s=t?.selectionStart??0,n=!r.slice(0,s).includes("\n");if((""===r.trim()||n)&&m){e.preventDefault();const t=m(r);if(null==t)return;a({target:{value:t}}),setTimeout(()=>{if(!h.current)return;const e=t.length;h.current.setSelectionRange(e,e)},0)}}if("ArrowDown"===e.key){const t=h.current,s=t?.selectionStart??0,n=!r.slice(s).includes("\n");if((""===r.trim()||n)&&g){e.preventDefault();const t=g();if(null==t)return;a({target:{value:t}}),setTimeout(()=>{if(!h.current)return;const e=t.length;h.current.setSelectionRange(e,e)},0)}}},placeholder:(0,t.__)("Ask me anything…","clawpress"),disabled:i}),(0,s.jsxs)("div",{className:"clawpress-input-footer",children:[(0,s.jsx)("div",{className:"clawpress-context-slot",children:j?(0,s.jsxs)("div",{className:"clawpress-context-indicator",role:"img",tabIndex:0,"aria-label":(0,t.sprintf)(/* translators: 1: context usage summary, 2: token usage summary */ /* translators: 1: context usage summary, 2: token usage summary */ +(0,t.__)("Context window: %1$s. %2$s.","clawpress"),k,S),children:[(0,s.jsx)("span",{className:"clawpress-context-pie",style:{"--clawpress-context-used":`${N}%`},"aria-hidden":"true"}),(0,s.jsxs)("div",{className:"clawpress-context-tooltip",role:"tooltip",children:[(0,s.jsx)("div",{className:"clawpress-context-tooltip-title",children:(0,t.__)("Context window:","clawpress")}),(0,s.jsx)("div",{className:"clawpress-context-tooltip-line",children:k}),(0,s.jsx)("div",{className:"clawpress-context-tooltip-line",children:S}),(0,s.jsx)("div",{className:"clawpress-context-tooltip-note",children:(0,t.__)("Codex automatically compacts its context","clawpress")})]})]}):null}),i?(0,s.jsx)("button",{className:"button",onClick:o,type:"button",children:(0,t.__)("Stop","clawpress")}):(0,s.jsx)("button",{className:"button button-primary",onClick:l,type:"button",children:(0,t.__)("Send","clawpress")})]})]})},o=e=>{if(!e||void 0===e.arguments||null===e.arguments)return{};if("string"==typeof e.arguments){if(""===e.arguments.trim())return{};try{return JSON.parse(e.arguments)}catch{return{raw:e.arguments}}}return e.arguments},i=({status:e,title:r,canRerun:a,policy:n,isOpen:l,error:o,children:i,onRun:c,onCancel:u,showActions:d,runLabelOverride:p})=>{const m=p||((e,s)=>"running"===e?(0,t.__)("Running…","clawpress"):"blocked"===e?(0,t.__)("Blocked","clawpress"):"done"===e&&s?(0,t.__)("Re-run","clawpress"):"error"===e?(0,t.__)("Retry","clawpress"):(0,t.__)("Run","clawpress"))(e,a),g="cancelled"===e,h="error"===e,w=void 0!==d?d:"running"!==e&&!g&&("done"!==e||a),f="blocked"===e,y=((e,s)=>"running"===e?(0,t.__)("Running…","clawpress"):"blocked"===e?"serial"===s?.concurrency?(0,t.__)("Blocked (waiting for earlier tool).","clawpress"):(0,t.__)("Blocked.","clawpress"):null)(e,n);return(0,s.jsxs)("details",{className:"clawpress-tool-dialog",open:!g&&l,children:[(0,s.jsxs)("summary",{className:"clawpress-tool-dialog-summary",children:[(0,s.jsxs)("span",{className:"clawpress-tool-dialog-heading",children:[(0,s.jsx)("span",{className:`clawpress-tool-dialog-status clawpress-tool-dialog-status-${e}`,"aria-hidden":"true"}),(0,s.jsx)("span",{className:"clawpress-tool-dialog-title",children:r})]}),w?(0,s.jsxs)("span",{className:"clawpress-tool-dialog-actions",children:[h?(0,s.jsx)("span",{className:"clawpress-tool-dialog-error-icon","aria-hidden":"true",children:(0,s.jsx)("svg",{viewBox:"0 0 24 24",role:"img",children:(0,s.jsx)("path",{d:"M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 6a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9a1 1 0 0 1 1-1Zm0 10a1.25 1.25 0 1 1 0-2.5A1.25 1.25 0 0 1 12 18Z"})})}):null,(0,s.jsx)("button",{className:"button button-primary",type:"button",onClick:c,disabled:"running"===e||f||g,children:m}),(0,s.jsx)("button",{className:"button",type:"button",onClick:u,children:(0,t.__)("Cancel","clawpress")})]}):null]}),(0,s.jsxs)("div",{className:"clawpress-tool-dialog-body",children:[g?(0,s.jsx)("p",{children:(0,t.__)("Cancelled.","clawpress")}):null,y?(0,s.jsx)("p",{children:y}):null,"error"===e?(0,s.jsx)("p",{children:o}):null,i]})]})},c=({fields:r,initialValues:a,onSubmit:n,onRun:l,onCancel:o,disabled:i})=>{const[c,u]=(0,e.useState)(a);(0,e.useEffect)(()=>{u(a)},[a]);const d=(e,t)=>u(s=>({...s,[e]:t}));return(0,s.jsxs)("form",{className:"clawpress-tool-dialog-form",onSubmit:e=>{e.preventDefault(),i||n(c)},children:[r.map(e=>{const t=c[e.name],r={id:`clawpress-tool-field-${e.name}`,name:e.name,disabled:i};if("hidden"===e.type)return(0,s.jsx)("input",{...r,type:"hidden",value:t??""},e.name);let a=null;return a="textarea"===e.type?(0,s.jsx)("textarea",{...r,className:"clawpress-tool-dialog-input",rows:e.rows||3,value:t??"",onChange:t=>d(e.name,t.target.value)}):"select"===e.type?(0,s.jsx)("select",{...r,className:"clawpress-tool-dialog-input",value:t??"",onChange:t=>d(e.name,t.target.value),children:e.options?.map(e=>(0,s.jsx)("option",{value:e.value,children:e.label},e.value))}):"checkbox"===e.type?(0,s.jsx)("input",{...r,type:"checkbox",checked:Boolean(t),onChange:t=>d(e.name,t.target.checked)}):(0,s.jsx)("input",{...r,className:"clawpress-tool-dialog-input",type:e.type||"text",value:t??"",onChange:t=>d(e.name,t.target.value)}),(0,s.jsxs)("label",{className:"clawpress-tool-dialog-field",htmlFor:r.id,children:[(0,s.jsx)("span",{className:"clawpress-tool-dialog-label",children:e.label}),a,e.help?(0,s.jsx)("span",{className:"clawpress-tool-dialog-help",children:e.help}):null]},e.name)}),(0,s.jsxs)("div",{className:"clawpress-tool-dialog-form-actions",children:[(0,s.jsx)("button",{className:"button button-primary",type:"submit",disabled:i,children:(0,t.__)("Preview","clawpress")}),l?(0,s.jsx)("button",{className:"button",type:"button",onClick:e=>{e.preventDefault(),i||l&&l(c)},disabled:i,children:(0,t.__)("Run","clawpress")}):null,o?(0,s.jsx)("button",{className:"button",type:"button",onClick:o,disabled:i,children:(0,t.__)("Cancel","clawpress")}):null]})]})},u="update_posts_find_replace",d=({toolName:e,args:r,toolDialog:a,runTool:n,onCancel:l,policy:o,isOpen:c})=>{const u=a.status||"idle",d=a.error||null,p=a.result||null,m=Boolean(o?.canRerun);return(0,s.jsxs)(i,{status:u,title:e||(0,t.__)("Unknown tool","clawpress"),canRerun:m,policy:o,isOpen:c,error:d,onRun:e=>{e.preventDefault(),e.stopPropagation(),n(a.id,a.args||r)},onCancel:e=>{e.preventDefault(),e.stopPropagation(),l(a.id)},children:["done"===u?(0,s.jsx)("syntax-highlight",{language:"json",children:JSON.stringify(p,null,2)}):(0,s.jsx)("syntax-highlight",{language:"json",children:JSON.stringify(r,null,2)}),a.diff?(0,s.jsxs)("div",{className:"clawpress-tool-dialog-diff",children:[(0,s.jsx)("h4",{children:(0,t.__)("Changes:","clawpress")}),(0,s.jsx)("syntax-highlight",{language:"json",children:JSON.stringify(a.diff,null,2)})]}):null]})},p={[u]:{renderer:({args:e,toolDialog:r,runTool:a,onCancel:n,policy:l,isOpen:o})=>{const u=e.search??"",d=e.replace??"",p=e.post_status??"any",m=r.status||"idle",g=r.error||null,h=r.result||null,w="done"===m?Boolean(h?.dry_run):Boolean(l?.canRerun),f="done"===m&&h?.dry_run,y="error"===m,_="blocked"===m,b=h?.total??0,x=Array.isArray(h?.changed)?h.changed:[],N=e=>{a(r.id,e)},v=[{name:"search",label:(0,t.__)("Find","clawpress"),type:"text",help:(0,t.__)("The text to search for.","clawpress")},{name:"replace",label:(0,t.__)("Replace","clawpress"),type:"text",help:(0,t.__)("The text to replace with.","clawpress")},{name:"post_status",label:(0,t.__)("Post status","clawpress"),type:"select",options:[{value:"any",label:(0,t.__)("Any","clawpress")},{value:"publish",label:(0,t.__)("Published","clawpress")},{value:"draft",label:(0,t.__)("Draft","clawpress")}]},{name:"dry_run",label:(0,t.__)("Dry run","clawpress"),type:"hidden"}],j="cancelled"===m?(0,t.__)("Update Posts - Find and Replace (Cancelled)","clawpress"):(0,t.__)("Update Posts - Find and Replace","clawpress");let k=null;return"cancelled"!==m&&(k=y?(0,s.jsxs)("div",{className:"clawpress-tool-result-actions",children:[(0,s.jsx)("button",{className:"button button-primary",type:"button",onClick:()=>N({...r.args||e,dry_run:!1!==r.args?.dry_run}),children:(0,t.__)("Retry","clawpress")}),(0,s.jsx)("button",{className:"button",type:"button",onClick:()=>n(r.id),children:(0,t.__)("Cancel","clawpress")})]}):"done"!==m?(0,s.jsx)(c,{fields:v,initialValues:{search:u,replace:d,post_status:p,dry_run:!0},disabled:"running"===m||_,onSubmit:e=>N({...e,dry_run:!0}),onRun:e=>N({...e,dry_run:!1}),onCancel:()=>n(r.id)}):(0,s.jsxs)("div",{className:"clawpress-tool-result",children:[(0,s.jsx)("div",{className:"clawpress-tool-result-summary",children:(0,s.jsx)("span",{children:f?(0,t.__)("Preview Results","clawpress"):(0,t.__)("Results","clawpress")})}),0===b?(0,s.jsx)("p",{children:(0,t.__)("No matches found.","clawpress")}):(0,s.jsxs)("details",{className:"clawpress-tool-result-list",open:!0,children:[(0,s.jsx)("summary",{children:(0,t.sprintf)(/* translators: %d: number of changed posts */ /* translators: %d: number of changed posts */ +(0,t.__)("Changed posts (%d)","clawpress"),b)}),(0,s.jsx)("ul",{children:x.map(e=>(0,s.jsxs)("li",{children:[(0,s.jsx)("span",{className:"clawpress-tool-result-title",children:e.title||(0,t.__)("Untitled","clawpress")}),(0,s.jsx)("span",{className:"clawpress-tool-result-meta",children:(0,t.sprintf)(/* translators: %d: post ID */ /* translators: %d: post ID */ +(0,t.__)("ID %d","clawpress"),e.id)}),(0,s.jsx)("span",{className:"clawpress-tool-result-meta",children:(0,t.sprintf)(/* translators: %d: number of replacements in a post */ /* translators: %d: number of replacements in a post */ +(0,t._n)("%d change","%d changes",e.count,"clawpress"),e.count)})]},e.id))})]}),f?(0,s.jsxs)("div",{className:"clawpress-tool-result-actions",children:[(0,s.jsx)("button",{className:"button button-primary",type:"button",onClick:()=>N({search:u,replace:d,post_status:p,dry_run:!1}),children:(0,t.__)("Run","clawpress")}),(0,s.jsx)("button",{className:"button",type:"button",onClick:()=>n(r.id),children:(0,t.__)("Cancel","clawpress")})]}):null]})),(0,s.jsx)(i,{status:m,title:j,canRerun:w,policy:l,isOpen:o,error:g,showActions:!1,onRun:e=>{e.preventDefault(),e.stopPropagation(),N({search:u,replace:d,post_status:p,dry_run:!1})},onCancel:e=>{e.preventDefault(),e.stopPropagation(),n(r.id)},children:k})},policy:{concurrency:"serial",canRerun:!1}}},m=e=>p[e]?.policy||{},g=({toolDialog:e,isOpen:t,onRunTool:r,onCancel:a})=>{if(!e)return null;const n=o(e.function),l=e.function?.name||"",i=(e=>p[e]?.renderer||d)(l),c=m(l);return(0,s.jsx)(i,{toolName:l,args:n,toolDialog:e,runTool:r,onCancel:a,policy:c,isOpen:t})},h=e=>Array.isArray(e?.data?.actions)?e.data.actions.filter(e=>e&&"object"==typeof e).map((e,t)=>{const s="string"==typeof e.label?e.label.trim():"",r="string"==typeof e.type&&e.type.trim()?e.type.trim().toLowerCase():"send_prompt";if(!s)return null;if("open_url"===r){const r=[e.url,e.href].map(e=>"string"==typeof e?e.trim():"").find(e=>e.length>0);return r?{id:"string"==typeof e.id&&e.id.trim()?e.id.trim():`action-${t}`,label:s,type:"open_url",url:r}:null}if("run_tool"===r){const r=[e.tool,e.tool_name,e.name].map(e=>"string"==typeof e?e.trim():"").find(e=>e.length>0);if(!r)return null;let a={};if(e.args&&"object"==typeof e.args&&!Array.isArray(e.args))a=e.args;else if("string"==typeof e.arguments)try{const t=JSON.parse(e.arguments);t&&"object"==typeof t&&!Array.isArray(t)&&(a=t)}catch{a={}}else e.arguments&&"object"==typeof e.arguments&&!Array.isArray(e.arguments)&&(a=e.arguments);return{id:"string"==typeof e.id&&e.id.trim()?e.id.trim():`action-${t}`,label:s,type:"run_tool",tool:r,args:a}}const a=[e.prompt,e.message,e.command].map(e=>"string"==typeof e?e.trim():"").find(e=>e.length>0);return a?{id:"string"==typeof e.id&&e.id.trim()?e.id.trim():`action-${t}`,label:s,type:"send_prompt",prompt:a}:null}).filter(e=>Boolean(e)):[],w=({card:e,onSendAction:r,isBusy:a=!1})=>{const n="string"==typeof e?.data?.title&&e.data.title.trim()?e.data.title:(0,t.__)("Welcome to ClawPress","clawpress"),l="string"==typeof e?.data?.message&&e.data.message.trim()?e.data.message:(0,t.__)("Hello! I am ready to help with your WordPress tasks.","clawpress"),o="string"==typeof e?.data?.subtitle&&e.data.subtitle.trim()?e.data.subtitle:"",i="string"==typeof e?.data?.emoji&&e.data.emoji.trim()?e.data.emoji:"👋",c=h(e);return(0,s.jsx)("div",{className:"clawpress-card clawpress-card-welcome",children:(0,s.jsxs)("div",{className:"clawpress-card-body",children:[(0,s.jsx)("div",{className:"clawpress-card-section-title",children:(0,s.jsxs)("div",{className:"clawpress-card-welcome-title-line",children:[(0,s.jsx)("span",{className:"clawpress-card-emoji","aria-hidden":"true",children:i}),(0,s.jsx)("div",{className:"clawpress-card-title",children:n})]})}),o?(0,s.jsx)("div",{className:"clawpress-card-section-subtitle",children:(0,s.jsx)("div",{className:"clawpress-card-subtitle",children:o})}):null,(0,s.jsx)("div",{className:"clawpress-card-section-content",children:(0,s.jsx)("div",{className:"clawpress-card-text",children:l})}),c.length>0?(0,s.jsx)("div",{className:"clawpress-card-section-buttons",children:(0,s.jsx)("div",{className:"clawpress-card-actions",children:c.map(e=>(0,s.jsx)("button",{type:"button",className:"button button-secondary button-small",onClick:()=>r?.(e),disabled:a,children:e.label},e.id))})}):null]})})},f="__clawpress_custom_model__",y=(e,t)=>e.filter(e=>"send_prompt"===e?.type&&"string"==typeof e.prompt&&e.prompt.startsWith(t)).map(e=>({...e,value:e.prompt.slice(t.length).trim()})).filter(e=>e.value.length>0),_=e=>"string"!=typeof e?"":e.replace(/\r\n/g,"\n").replace(//gi,"\n"),b=({card:r,onSendAction:a,isBusy:n=!1})=>{const l=r?.data&&"object"==typeof r.data&&!Array.isArray(r.data)?r.data:{},o="string"==typeof l.title&&l.title.trim()?l.title:(0,t.__)("Setup Wizard","clawpress"),i="string"==typeof l.emoji&&l.emoji.trim()?l.emoji:"🧙",c="string"==typeof l.message&&l.message.trim()?_(l.message):(0,t.__)("Follow these steps to finish setup.","clawpress"),u="string"==typeof l.detail&&l.detail.trim()?l.detail:"",d="string"==typeof l.error&&l.error.trim()?l.error:"",p=_(u),m=_(d),g="string"==typeof l.step?l.step.trim():"",w="string"==typeof l.step_label&&l.step_label.trim()?l.step_label:g,b=Number.isFinite(Number(l.step_index))?Number(l.step_index):null,x=Number.isFinite(Number(l.step_total))?Number(l.step_total):null,N=Array.isArray(l.steps)?l.steps:[],v=h(r),j=(0,e.useMemo)(()=>y(v,"/setup provider "),[v]),k=(0,e.useMemo)(()=>y(v,"/setup model "),[v]),S="string"==typeof l.selected_model&&l.selected_model.trim()?l.selected_model.trim():"",A=(0,e.useMemo)(()=>"model"!==g?k:[...k,{id:f,label:(0,t.__)("Use Custom Model ID","clawpress"),type:"send_prompt",prompt:""}],[k,g]),E=(0,e.useMemo)(()=>{const e=new Set([...j.map(e=>e.id),...k.map(e=>e.id)]);return v.filter(t=>!e.has(t.id))},[v,j,k]),C=(0,e.useMemo)(()=>{const e=E.some(e=>"send_prompt"===e?.type&&"string"==typeof e.prompt&&"/setup back"===e.prompt.trim());return"provider"===g||e?E:[{id:"wizard-back-fallback",label:(0,t.__)("Back","clawpress"),type:"send_prompt",prompt:"/setup back"},...E]},[E,g]),[R,M]=(0,e.useState)(j[0]?.id||""),[$,T]=(0,e.useState)(A[0]?.id||""),[D,P]=(0,e.useState)("");(0,e.useEffect)(()=>{const e=j.map(e=>e.id);0!==e.length?e.includes(R)||M(e[0]):M("")},[j,R]),(0,e.useEffect)(()=>{const e=A.map(e=>e.id);0!==e.length?e.includes($)||T(e[0]):T("")},[A,$]),(0,e.useEffect)(()=>{if("model"!==g||!S)return;const e=k.find(e=>e.value===S);e?T(e.id):(T(f),P(S))},[k,S,g]);const q=j.find(e=>e.id===R)||j[0]||null,I=k.find(e=>e.id===$)||k[0]||null,L="model"===g&&$===f,F=D.trim(),O="string"==typeof l.settings_url&&l.settings_url.trim()?l.settings_url.trim():"",B=(0,e.useMemo)(()=>{if(!O||"undefined"==typeof window)return!1;try{const e=new URL(window.location.href),t=new URL(O,window.location.origin);return e.pathname===t.pathname&&e.search===t.search}catch{return!1}},[O]),U=b&&x?(0,t.sprintf)(/* translators: 1: current step number, 2: total step count, 3: step label. */ /* translators: 1: current step number, 2: total step count, 3: step label. */ +(0,t.__)("Step %1$d OF %2$d : %3$s","clawpress"),b,x,w||""):"",W="workspace"===g&&"string"==typeof l.workspace_path&&l.workspace_path.trim()?(e=>{if("string"!=typeof e)return"";const t=e.trim().replace(/\\/g,"/");if(!t)return"";const s=t.indexOf("/wp-content");return-1===s?t:t.slice(s)})(l.workspace_path):"",H="workspace"!==g?"":"string"==typeof l.workspace_exists&&l.workspace_exists.trim()?l.workspace_exists.trim():"string"==typeof l.workspace_exists_line&&l.workspace_exists_line.trim()?l.workspace_exists_line.replace(/^Exists\s*:\s*/i,"").trim():"",J="provider"===g&&j.length>1,z="model"===g&&A.length>0,G="provider"===g&&1===j.length,K="model"===g&&!z&&1===k.length,X=G||K||C.length>0,V=J||z||X;let Z="";Z=L?F?`/setup model ${F}`:"":I?.prompt||"";const Y="provider"===g?q?.prompt||"":Z,Q=n||"provider"===g&&!q||"model"===g&&(L?0===F.length:!I);return(0,s.jsx)("div",{className:"clawpress-card clawpress-card-setup",children:(0,s.jsxs)("div",{className:"clawpress-card-body",children:[(0,s.jsx)("div",{className:"clawpress-card-section-title",children:(0,s.jsxs)("div",{className:"clawpress-card-setup-title-line",children:[(0,s.jsx)("span",{className:"clawpress-card-setup-emoji","aria-hidden":"true",children:i}),(0,s.jsx)("div",{className:"clawpress-card-title",children:o})]})}),U||N.length>0?(0,s.jsxs)("div",{className:"clawpress-card-section-subtitle",children:[U?(0,s.jsx)("div",{className:"clawpress-card-subtitle clawpress-card-setup-step-line",children:U}):null,N.length>0?(0,s.jsx)("div",{className:"clawpress-card-setup-progress","aria-hidden":"true",children:N.map((e,t)=>{const r="string"==typeof e?.status?e.status:"pending",a=["pending","done","current","completed"].includes(r)?r:"pending";return(0,s.jsxs)("div",{className:`clawpress-card-setup-progress-item clawpress-card-setup-progress-${a}`,children:[(0,s.jsx)("span",{className:"clawpress-card-setup-progress-dot",children:t+1}),tM(e.target.value),disabled:n,children:j.map(e=>(0,s.jsx)("option",{value:e.id,children:e.label},e.id))}):null,z?(0,s.jsx)("select",{className:"clawpress-card-setup-select",value:$,onChange:e=>T(e.target.value),disabled:n,children:A.map(e=>(0,s.jsx)("option",{value:e.id,children:e.label},e.id))}):null,L?(0,s.jsx)("input",{type:"text",className:"clawpress-card-setup-input",value:D,onChange:e=>P(e.target.value),placeholder:(0,t.__)("Enter custom model ID","clawpress"),disabled:n}):null,(0,s.jsx)("button",{type:"button",className:"button button-primary button-small",disabled:Q,onClick:()=>a?.(Y),children:"provider"===g?(0,t.__)("Use Selected Provider","clawpress"):(0,t.__)("Use Selected Model","clawpress")})]}):null,X?(0,s.jsxs)("div",{className:"clawpress-card-actions",children:[G?(0,s.jsx)("button",{type:"button",className:"button button-primary button-small",onClick:()=>a?.(j[0]),disabled:n,children:j[0].label}):null,K?(0,s.jsx)("button",{type:"button",className:"button button-primary button-small",onClick:()=>a?.(k[0]),disabled:n,children:k[0].label}):null,C.map(e=>(0,s.jsx)("button",{type:"button",className:"button button-secondary button-small",onClick:()=>a?.(e),disabled:n,children:e.label},e.id))]}):null]}):null]})})},x=({card:e})=>{const r="string"==typeof e?.data?.title&&e.data.title.trim()?e.data.title:(0,t.__)("Request Error","clawpress"),a="string"==typeof e?.data?.message&&e.data.message.trim()?e.data.message:(0,t.__)("An unknown error occurred.","clawpress"),n="string"==typeof e?.data?.subtitle&&e.data.subtitle.trim()?e.data.subtitle:"";return(0,s.jsx)("div",{className:"clawpress-card clawpress-card-error",role:"alert","aria-live":"polite",children:(0,s.jsxs)("div",{className:"clawpress-card-body",children:[(0,s.jsx)("div",{className:"clawpress-card-section-title",children:(0,s.jsx)("div",{className:"clawpress-card-title",children:r})}),n?(0,s.jsx)("div",{className:"clawpress-card-section-subtitle",children:(0,s.jsx)("div",{className:"clawpress-card-subtitle",children:n})}):null,(0,s.jsx)("div",{className:"clawpress-card-section-content",children:(0,s.jsx)("div",{className:"clawpress-card-text",children:a})})]})})},N=({card:e,onSendAction:r,isBusy:a=!1})=>{const n="string"==typeof e?.data?.title&&e.data.title.trim()?e.data.title:(0,t.__)("User Confirmation Required","clawpress"),l="string"==typeof e?.data?.subtitle&&e.data.subtitle.trim()?e.data.subtitle:(0,t.__)("Destructive action pending","clawpress"),o="string"==typeof e?.data?.message&&e.data.message.trim()?e.data.message:(0,t.__)("Please confirm or decline this action.","clawpress"),i=h(e);return(0,s.jsx)("div",{className:"clawpress-card clawpress-card-user-confirmation",role:"alert","aria-live":"polite",children:(0,s.jsxs)("div",{className:"clawpress-card-body",children:[(0,s.jsx)("div",{className:"clawpress-card-section-title",children:(0,s.jsx)("div",{className:"clawpress-card-title",children:n})}),l?(0,s.jsx)("div",{className:"clawpress-card-section-subtitle",children:(0,s.jsx)("div",{className:"clawpress-card-subtitle",children:l})}):null,(0,s.jsx)("div",{className:"clawpress-card-section-content",children:(0,s.jsx)("div",{className:"clawpress-card-text",children:o})}),i.length>0?(0,s.jsx)("div",{className:"clawpress-card-section-buttons",children:(0,s.jsx)("div",{className:"clawpress-card-actions",children:i.map((e,t)=>(0,s.jsx)("button",{type:"button",className:"button button-small "+(0===t?"button-primary":"button-secondary"),onClick:()=>r?.(e),disabled:a,children:e.label},e.id))})}):null]})})},v=({card:e,fallbackText:r,onSendAction:a,isBusy:n=!1})=>{if(!e||"object"!=typeof e)return(0,s.jsx)("div",{className:"clawpress-msg-content",children:r||""});switch(e.type){case"welcome":return(0,s.jsx)(w,{card:e,onSendAction:a,isBusy:n});case"setup":return(0,s.jsx)(b,{card:e,onSendAction:a,isBusy:n});case"error":return(0,s.jsx)(x,{card:e});case"user_confirmation":return(0,s.jsx)(N,{card:e,onSendAction:a,isBusy:n})}const l="string"==typeof e?.data?.title&&e.data.title.trim()?e.data.title:(0,t.__)("Card","clawpress"),o="string"==typeof e?.data?.message&&e.data.message.trim()?e.data.message:r||"",i="string"==typeof e?.data?.subtitle&&e.data.subtitle.trim()?e.data.subtitle:"",c=h(e);return o||0!==c.length?(0,s.jsx)("div",{className:"clawpress-card clawpress-card-generic",children:(0,s.jsxs)("div",{className:"clawpress-card-body",children:[l?(0,s.jsx)("div",{className:"clawpress-card-section-title",children:(0,s.jsx)("div",{className:"clawpress-card-title",children:l})}):null,i?(0,s.jsx)("div",{className:"clawpress-card-section-subtitle",children:(0,s.jsx)("div",{className:"clawpress-card-subtitle",children:i})}):null,o?(0,s.jsx)("div",{className:"clawpress-card-section-content",children:(0,s.jsx)("div",{className:"clawpress-card-text",children:o})}):null,c.length>0?(0,s.jsx)("div",{className:"clawpress-card-section-buttons",children:(0,s.jsx)("div",{className:"clawpress-card-actions",children:c.map(e=>(0,s.jsx)("button",{type:"button",className:"button button-secondary button-small",onClick:()=>a?.(e),disabled:n,children:e.label},e.id))})}):null]})}):(0,s.jsx)("div",{className:"clawpress-msg-content",children:r||""})},j=e=>"string"==typeof e&&e?e.split(/(`[^`\n]+`|\*\*[^*\n]+?\*\*)/g).map((e,t)=>{const r=e.match(/^\*\*([^*\n]+?)\*\*$/);if(r)return(0,s.jsx)("strong",{children:r[1]},`strong-${t}`);const a=e.match(/^`([^`\n]+)`$/);return a?(0,s.jsx)("code",{children:a[1]},`code-${t}`):e}):e||"",k=({messages:r,streaming:a,currentStreamText:n,waitingForResponse:l,toolDialogs:o,onRunToolDialog:i,onCancelToolDialog:c,onSendCardAction:u})=>{const d=(0,e.useRef)(null),p=new Set([(0,t.__)("I am still working on this","clawpress"),(0,t.__)("Yes, still working on it.","clawpress"),(0,t.__)("Ok, this is taking long now. Still on it.","clawpress"),(0,t.__)("Plot twist: still working on it. My keyboard is sweating.","clawpress"),(0,t.__)("At this point even my coffee is worried, but I am still on it.","clawpress")]),h=e=>"string"==typeof e&&p.has(e.trim());(0,e.useEffect)(()=>{const e=d.current;e&&(e.scrollTop=e.scrollHeight)},[r,o,a,n,l]);const w=o.filter(e=>"serial"===m(e.function?.name||"").concurrency&&"done"!==e.status&&"cancelled"!==e.status).sort((e,t)=>e.createdAt-t.createdAt),f=w[0]?.id||o[o.length-1]?.id||null,y=r.length-1,_=y>=0?r[y]:null,b=h(_?.content||""),x=[...r.map((e,t)=>({type:"message",createdAt:e.createdAt??0,messageIndex:t,data:e})),...o.map(e=>({type:"tool",createdAt:e.createdAt??0,data:e}))].sort((e,t)=>{const s=e.createdAt-t.createdAt;return 0!==s?s:"message"===e.type&&"message"===t.type?(e.messageIndex??0)-(t.messageIndex??0):"message"===e.type?-1:"message"===t.type?1:0});return(0,s.jsxs)("div",{className:"clawpress-messages",ref:d,children:[x.map(e=>"message"===e.type?(()=>{const r=e.data.content||"",n=h(r);if(n&&e.messageIndex!==y)return null;const o=n?"system":e.data.role,i="system"===o,c="assistant"===o,d=e.data.card&&"object"==typeof e.data.card,p=d&&e.messageIndex===y,m=/(\.\.\.|…)\s*$/.test(r),g=i&&m||n,w=g?r.replace(/\s*(\.\.\.|…)\s*$/,""):r;return(0,s.jsxs)("div",{className:`clawpress-msg clawpress-${o}`,children:[!i&&!c||n?null:(0,s.jsx)("div",{className:"clawpress-msg-label",children:i?(0,t.__)("System","clawpress"):(0,t.__)("AGENT","clawpress")}),d?(0,s.jsx)(v,{card:e.data.card,fallbackText:w,onSendAction:u,isBusy:a||l||!p}):(0,s.jsx)("div",{className:"clawpress-msg-content"+(g?" clawpress-thinking":""),children:j(w)})]},e.data.id||e.data.content)})():(0,s.jsx)(g,{toolDialog:e.data,isOpen:e.data.id===f,onRunTool:i,onCancel:c},e.data.id)),!l||n||b?null:(0,s.jsx)("div",{className:"clawpress-msg clawpress-system",children:(0,s.jsx)("div",{className:"clawpress-msg-content clawpress-thinking",children:(0,t.__)("Thinking","clawpress")})}),a&&n?(0,s.jsxs)("div",{className:"clawpress-msg clawpress-assistant",children:[(0,s.jsx)("div",{className:"clawpress-msg-label",children:(0,t.__)("AGENT","clawpress")}),(0,s.jsx)("div",{className:"clawpress-msg-content",children:j(n||"...")})]}):null]})},S=({onToggle:e})=>(0,s.jsx)("button",{className:"button button-primary clawpress-toggle",onClick:e,type:"button",children:(0,t.__)("ClawPress","clawpress")}),A=[(0,t.__)("I am still working on this","clawpress"),(0,t.__)("Yes, still working on it.","clawpress"),(0,t.__)("Ok, this is taking long now. Still on it.","clawpress"),(0,t.__)("Plot twist: still working on it. My keyboard is sweating.","clawpress"),(0,t.__)("At this point even my coffee is worried, but I am still on it.","clawpress")],E=new Set(["done","success","error","timeout","requires_confirmation","failed","cancelled","canceled"]),C=async({url:e,method:s="GET",nonce:r,body:a,signal:n})=>{const l=await fetch(e,{method:s,credentials:"same-origin",headers:{"Content-Type":"application/json","X-WP-Nonce":r},body:a?JSON.stringify(a):void 0,signal:n}),o=await l.text();let i={};if(o)try{i=JSON.parse(o)}catch{i={message:o}}if(!l.ok){const e=i?.message||i?.error||(0,t.sprintf)(/* translators: %d: HTTP status code */ /* translators: %d: HTTP status code */ +(0,t.__)("Request failed (%d)","clawpress"),l.status);throw new Error(e)}return i},R=e=>Boolean(e)&&"object"==typeof e&&!Array.isArray(e),M=({mockEnabled:e,mockScenario:s,mockDelay:r,restBase:a,streamNonce:n,nonce:l,onEvent:o,onDone:i,onError:c})=>e?(({mockScenario:e,mockDelay:s,onEvent:r,onDone:a,onError:n})=>({stream:l=>(({prompt:e,mode:s="normal",delayMode:r="normal",onEvent:a,onDone:n,onError:l})=>{const o=[];let i=!1;const c="slow"===r?3e3:0,u=(e,t)=>{const s=setTimeout(()=>{i||e()},t+c);o.push(s)},d=(e,t)=>{i||a({type:e,payload:t})},p=()=>{i=!0,o.forEach(clearTimeout),n?.({aborted:!0})};if("infinite"===r)return{stop:p};if("error"===s)return u(()=>l?.({error:(0,t.__)("Mock error: something went wrong.","clawpress")}),300),u(()=>n?.({aborted:!1}),600),{stop:p};"tool"!==s&&"tool_error"!==s||(u(()=>d("tool_call",{function:{name:"update_posts_find_replace",arguments:"{}"}}),200),u(()=>d("tool_plan",{function:{name:"update_posts_find_replace",arguments:{search:"tool_error"===s?(0,t.sprintf)(/* translators: %s: mock text prefixed with ERROR to trigger an error path */ /* translators: %s: mock text prefixed with ERROR to trigger an error path */ +(0,t.__)("ERROR: %s","clawpress"),(0,t.__)("Old Phrase","clawpress")):(0,t.__)("Old Phrase","clawpress"),replace:"tool_error"===s?(0,t.sprintf)(/* translators: %s: mock text prefixed with ERROR to trigger an error path */ /* translators: %s: mock text prefixed with ERROR to trigger an error path */ +(0,t.__)("ERROR: %s","clawpress"),(0,t.__)("New Phrase","clawpress")):(0,t.__)("New Phrase","clawpress"),post_status:"tool_error"===s?"draft":"publish",dry_run:!0}}}),700));const m=(0,t.__)("Here is a longer mock response to help you test streaming behavior.","clawpress")+(0,t.__)("It should keep streaming long enough for you to press Stop and see the UI react.","clawpress")+(0,t.__)("We can include multiple sentences, line breaks, and a bit of variety.","clawpress")+(0,t.__)("Chunk one: The quick brown fox jumps over the lazy dog.","clawpress")+(0,t.__)("Chunk two: Sphinx of black quartz, judge my vow.","clawpress")+(0,t.__)("Chunk three: Pack my box with five dozen liquor jugs.","clawpress")+(0,t.__)("Final chunk: This should be enough to test canceling a long stream.","clawpress");let g=(0,t.sprintf)(/* translators: %s: user prompt */ /* translators: %s: user prompt */ +(0,t.__)('Here is a mock response for: "%s"',"clawpress"),e);"tool"===s||"tool_error"===s?g=(0,t.__)("I found a tool that can update posts. Here is the proposed change.","clawpress"):"long"===s&&(g=m);const h=g.split("");let w=150;return h.forEach(e=>{u(()=>d("delta",{text:e}),w),w+=12}),u(()=>n?.({aborted:!1}),w+200),{stop:p}})({prompt:l,mode:e,delayMode:s,onEvent:({type:e,payload:t})=>r(e,t),onDone:()=>a?.({aborted:!1}),onError:n}),runTool:(e,r)=>(async(e,s,{mockDelay:r="normal"}={})=>{if("infinite"===r)await new Promise(()=>{});else{const e="slow"===r?3e3:300;await new Promise(t=>setTimeout(t,e))}if(("string"==typeof s?.search?s.search:"").includes("ERROR"))throw new Error((0,t.__)("Mock tool error: execution failed.","clawpress"));return{step:{data:{result:{dry_run:!0,total:2,changed:[{id:123,title:(0,t.__)("Hello World","clawpress"),count:1},{id:456,title:(0,t.__)("Sample Post","clawpress"),count:2}],tool:e,args:s}}}}})(e,r,{mockDelay:s}),getHistory:async()=>({items:[]}),getStatus:async()=>({mode:"offline",provider:{id:null,configured:!1},model:{id:null,configured:!1},setup:{completed:!1},memory:{enabled:!1},agent_user:{id:null,configured:!1},suggestions:["/help","/clear","/status","/setup resume","/memory list","/site info","/tools list"]}),getPanelState:async()=>({open:!1,width:420,last_history_id:""}),setPanelState:async e=>({open:Boolean(e?.open),width:Number(e?.width)||420,last_history_id:"string"==typeof e?.last_history_id?e.last_history_id:""})}))({mockScenario:s,mockDelay:r,onEvent:o,onDone:i,onError:c}):(({restBase:e,nonce:s,onEvent:r,onDone:a,onError:n})=>{const l=(t,r)=>C({url:`${e}/agent/runs/${t}`,method:"GET",nonce:s,signal:r}),o=(t,r,a)=>C({url:`${e}/agent/runs/${t}/events?after=${r}&limit=100`,method:"GET",nonce:s,signal:a}),i=e=>new Promise((t,s)=>{const r=setTimeout(()=>{e?.removeEventListener("abort",a),t()},1e3),a=()=>{clearTimeout(r),e?.removeEventListener("abort",a);const t=new Error("Aborted");t.name="AbortError",s(t)};e?.addEventListener("abort",a,{once:!0})}),c=e=>{const t=Number.isFinite(Number(e))?Math.max(0,Number(e)):0,s=Math.min(Math.floor(t/8),A.length-1);return A[s]||A[0]},u=e=>{if(!e||"object"!=typeof e)return null;const t="string"==typeof e.type?e.type.trim():"";return t?{type:t,data:e.data&&"object"==typeof e.data&&!Array.isArray(e.data)?e.data:{}}:null},d=e=>{if(!e||"object"!=typeof e)return null;const t=e=>{const t=Number(e);return!Number.isFinite(t)||t<0?null:Math.round(t)},s=e=>{if(null==e)return null;const t=Number(e);return Number.isFinite(t)?Math.max(0,Math.min(100,Math.round(t))):null},r=t(e.prompt_tokens)??0,a=t(e.completion_tokens)??0,n=t(e.total_tokens)??0,l=t(e.used_tokens)??(r>0?r:n),o=t(e.context_window_tokens)??null;return 0===r&&0===a&&0===n&&0===l&&null===o?null:{promptTokens:r,completionTokens:a,totalTokens:n,usedTokens:l,contextWindowTokens:o,percentUsed:s(e.percent_used),percentLeft:s(e.percent_left),windowIsEstimated:"boolean"==typeof e.window_is_estimated?e.window_is_estimated:null}},p=e=>{if(!e||"object"!=typeof e)return null;const t="string"==typeof e.name?e.name.trim():"";if(!t)return null;const s="string"==typeof e.ability?e.ability.trim():"",r=e.args&&"object"==typeof e.args&&!Array.isArray(e.args)?e.args:{},a=(e=>{const t="string"==typeof e?e.trim().toLowerCase():"";return"success"===t||"error"===t||"requires_confirmation"===t?t:"success"})(e.status);return{name:t,ability:s||null,args:r,status:a,message:("string"==typeof e.message&&e.message.trim()?e.message.trim():"")||null,round:Number.isFinite(Number(e.round))?Math.max(1,Math.round(Number(e.round))):1,sequence:Number.isFinite(Number(e.sequence))?Math.max(1,Math.round(Number(e.sequence))):1,requiresConfirmation:"boolean"==typeof e.requires_confirmation?e.requires_confirmation:"requires_confirmation"===a}},m=e=>"string"==typeof e?e.trim().toLowerCase():"",g=(e,t,s,a)=>{const n=(e=>{if(!e||"object"!=typeof e)return"";const t="string"==typeof e.name?e.name.trim():"";if(!t)return"";const s="string"==typeof e.status?e.status.trim().toLowerCase():"",r="string"==typeof e.message?e.message.trim():"";return`${t}|${s}|${Number.isFinite(Number(e.round))?Math.max(1,Math.round(Number(e.round))):1}|${Number.isFinite(Number(e.sequence))?Math.max(1,Math.round(Number(e.sequence))):1}|${r}`})(e);if(n&&a instanceof Set){if(a.has(n))return;a.add(n)}r("tool_call",{call:e,index:t,total:s})},h=(e,t)=>{if(!Array.isArray(e)||0===e.length)return;const s=e.filter(e=>R(e)&&"string"==typeof e.event_type&&"agent.tool_call"===e.event_type);if(s.length>0)return void s.forEach((e,r)=>{const a=R(e.payload)?e.payload:{},n=m(a.status);let l="tool_call";"string"==typeof a.tool_name&&a.tool_name.trim()?l=a.tool_name.trim():"string"==typeof a.ability_name&&a.ability_name.trim()&&(l=a.ability_name.trim());let o="success";"error"!==n&&"requires_confirmation"!==n||(o=n);const i="string"==typeof a.message&&a.message.trim()?a.message.trim():"",c={name:l,ability:"string"==typeof a.ability_name&&a.ability_name.trim()?a.ability_name.trim():null,args:{},status:o,message:i||null,round:Number.isFinite(Number(a.round))?Math.max(1,Math.round(Number(a.round))):1,sequence:Number.isFinite(Number(a.sequence))?Math.max(1,Math.round(Number(a.sequence))):r+1,requiresConfirmation:"requires_confirmation"===n};g(c,r+1,s.length,t)});const r=e.filter(e=>R(e)&&"string"==typeof e.event_type&&"tool_call"===e.event_type);0!==r.length&&r.forEach((e,s)=>{const a=R(e.payload)?e.payload:{},n=m(a.status),l=R(a.result)?a.result:{},o=R(a.error)?a.error:{};let i="";"string"==typeof o.message&&o.message.trim()?i=o.message.trim():"string"==typeof l.message&&l.message.trim()&&(i=l.message.trim());let c="tool_call";"string"==typeof a.tool_name&&a.tool_name.trim()?c=a.tool_name.trim():"string"==typeof a.ability_name&&a.ability_name.trim()&&(c=a.ability_name.trim());let u="success";"error"!==n&&"requires_confirmation"!==n||(u=n);const d={name:c,ability:"string"==typeof a.ability_name&&a.ability_name.trim()?a.ability_name.trim():null,args:{},status:u,message:i||null,round:Number.isFinite(Number(a.round))?Math.max(1,Math.round(Number(a.round))):1,sequence:Number.isFinite(Number(a.sequence))?Math.max(1,Math.round(Number(a.sequence))):s+1,requiresConfirmation:"requires_confirmation"===n};g(d,s+1,r.length,t)})},w=(e,s,a)=>{const n=R(e?.meta)?e.meta:{};let l=null;if(R(n.result)?l=n.result:R(n.last_result)&&(l=n.last_result),((e,s,a)=>{if(!R(e))return!1;const n=d(e.context);n&&r("context_usage",{context:n});const l=Array.isArray(e.tool_calls)?e.tool_calls.map(e=>p(e)).filter(Boolean):[];if(l.forEach((e,t)=>{g(e,t+1,l.length,a)}),R(e.error)){const s=e.error,a="string"==typeof s.message&&s.message.trim()?s.message.trim():(0,t.__)("Run failed.","clawpress");return r("error",{error:a,type:"string"==typeof s.type&&s.type.trim()?s.type.trim():"provider",card:u(e.card)}),!0}const o="string"==typeof e.assistant_text?e.assistant_text.trim():"",i=u(e.card);return i?(r("response_card",{card:i,text:o,role:"assistant"}),!0):!(!o||o===s||(r("response_message",{text:o,role:"assistant"}),0))})(l,s,a))return;const o=m(e?.status);if("requires_confirmation"===o)return void r("response_message",{text:(0,t.__)("Action requires confirmation before continuing.","clawpress"),role:"assistant"});if("done"===o||"success"===o)return void r("response_message",{text:(0,t.__)("I finished the background steps, but I did not receive a final text response. Please tell me to continue and I will pick up from here.","clawpress"),role:"assistant"});const i="string"==typeof e?.error_message&&e.error_message.trim()?e.error_message.trim():(0,t.__)("Run ended with an error.","clawpress");r("error",{error:i,type:"timeout"===o?"timeout":"provider"})};return{stream:f=>{const y=new AbortController,_=new Set;return(async()=>{try{const n=await(b=f,x=y.signal,C({url:`${e}/chat/message`,method:"POST",nonce:s,body:{message:b},signal:x}));n?.meta?.command?.effects&&!0===n.meta.command.effects.clear_history&&r("history_reset",{}),Array.isArray(n?.meta?.suggestions)&&r("suggestions",{items:n.meta.suggestions});const N=d(n?.meta?.context);N&&r("context_usage",{context:N});const v=Array.isArray(n?.meta?.tool_calls)?n.meta.tool_calls.map(e=>p(e)).filter(Boolean):[];v.forEach((e,t)=>{g(e,t+1,v.length,_)});const j=n?.meta?.error&&"object"==typeof n.meta.error?n.meta.error:null,k=n?.meta?.card&&"object"==typeof n.meta.card?u(n.meta.card):null;if(j){const e="string"==typeof j.message&&j.message.trim()?j.message.trim():(0,t.__)("Chat request failed.","clawpress");return r("error",{error:e,type:"string"==typeof j.type&&j.type.trim()?j.type.trim():"provider",card:k}),void a?.({aborted:!1})}const S="string"==typeof n?.reply?n.reply.trim():"",A=Boolean(n?.meta?.command?.name),R=u(n?.meta?.card);R?r("response_card",{card:R,text:S,role:A?"system":"assistant"}):S&&r("response_message",{text:S,role:A?"system":"assistant"});const M=m(n?.meta?.status),$=Number(n?.meta?.run_id),T=Number(n?.meta?.events_cursor);"in_progress"===M&&(Number.isFinite($)&&$>0?await(async({runId:e,initialReply:s,signal:a,initialEventsCursor:n=0,seenToolCallKeys:u=null})=>{let d=Number.isFinite(Number(n))?Math.max(0,Math.round(Number(n))):0;const p=Date.now();let g="";for(;;){if(a?.aborted){const e=new Error("Aborted");throw e.name="AbortError",e}const n=(Date.now()-p)/1e3;if(n>=180)return void r("error",{error:(0,t.__)("Run polling timed out before a terminal status was received.","clawpress"),type:"timeout"});try{const t=await o(e,d,a),s=Array.isArray(t?.events)?t.events:[];h(s,u);const r=Number(t?.next_cursor);Number.isFinite(r)&&r>0&&(d=Math.max(d,r))}catch(e){if("AbortError"===e?.name)throw e}const f=await l(e,a),y=m(f?.status);if(E.has(y))return void w(f,s,u);const _=c(n);_&&_!==g&&(r("run_progress",{text:_}),g=_),await i(a)}})({runId:$,initialReply:S,signal:y.signal,initialEventsCursor:T,seenToolCallKeys:_}):r("error",{error:(0,t.__)("Run entered progress mode without a valid run ID.","clawpress"),type:"request"})),a?.({aborted:!1})}catch(e){if("AbortError"===e?.name)return void a?.({aborted:!0});n?.({error:e?.message||(0,t.__)("Chat request failed.","clawpress"),type:"request"}),a?.({aborted:!1})}var b,x})(),{stop:()=>y.abort()}},runTool:async()=>{throw new Error((0,t.__)("Tool execution is not available in chat mode.","clawpress"))},getHistory:()=>C({url:`${e}/chat/history`,method:"GET",nonce:s}),getStatus:()=>C({url:`${e}/status`,method:"GET",nonce:s}),getPanelState:()=>C({url:`${e}/panel/state`,method:"GET",nonce:s}),setPanelState:t=>C({url:`${e}/panel/state`,method:"POST",nonce:s,body:t})}})({restBase:a,streamNonce:n,nonce:l,onEvent:o,onDone:i,onError:c}),$=({mockEnabled:e,mockScenario:r,mockDelay:a,onSelectScenario:n,onSelectDelay:l,onRunScenario:o,themeMode:i})=>{if(!e)return null;const c=[{key:"normal",label:(0,t.__)("Normal","clawpress")},{key:"long",label:(0,t.__)("Long","clawpress")},{key:"tool",label:(0,t.__)("Tool","clawpress")},{key:"tool_error",label:(0,t.__)("Tool Error","clawpress")},{key:"error",label:(0,t.__)("Error","clawpress")}],u=[{key:"normal",label:(0,t.__)("Normal","clawpress")},{key:"slow",label:(0,t.__)("Slow (3s)","clawpress")},{key:"infinite",label:(0,t.__)("Infinite","clawpress")}];return(0,s.jsxs)("div",{className:"clawpress-mock-panel","data-theme":i,children:[(0,s.jsx)("div",{className:"clawpress-mock-badge",children:(0,t.__)("Mock","clawpress")}),(0,s.jsxs)("fieldset",{className:"clawpress-mock-fieldset",children:[(0,s.jsx)("legend",{children:(0,t.__)("Scenario","clawpress")}),(0,s.jsx)("div",{className:"clawpress-mock-buttons",children:c.map(e=>(0,s.jsx)("button",{className:"button "+(r===e.key?"button-primary":""),type:"button",onClick:()=>n(e.key),children:e.label},e.key))})]}),(0,s.jsxs)("fieldset",{className:"clawpress-mock-fieldset",children:[(0,s.jsx)("legend",{children:(0,t.__)("Response delay","clawpress")}),(0,s.jsx)("div",{className:"clawpress-mock-buttons",children:u.map(e=>(0,s.jsx)("button",{className:"button "+(a===e.key?"button-primary":""),type:"button",onClick:()=>l(e.key),children:e.label},e.key))})]}),(0,s.jsx)("button",{className:"button",type:"button",onClick:o,children:(0,t.__)("Run","clawpress")})]})},T=()=>{const[r,n]=(0,e.useState)(JSON.parse(localStorage.getItem("clawpress_open")||"false")),[i,c]=(0,e.useState)(Number(localStorage.getItem("clawpress_width")||CLAWPRESS_PANEL.defaultWidth)),[u,d]=(0,e.useState)([]),[p,g]=(0,e.useState)(""),[h,w]=(0,e.useState)(()=>{const e=localStorage.getItem("clawpress_input_history");if(!e)return[];try{const t=JSON.parse(e);return Array.isArray(t)?t.filter(e=>"string"==typeof e):[]}catch{return[]}}),[f,y]=(0,e.useState)(-1),[_,b]=(0,e.useState)(""),[x,N]=(0,e.useState)(!1),[v,j]=(0,e.useState)(""),[A,E]=(0,e.useState)(!1),[C,R]=(0,e.useState)([]),[T,D]=(0,e.useState)([]),[P,q]=(0,e.useState)(!1),[I,L]=(0,e.useState)(null),[F,O]=(0,e.useState)(!0),[B,U]=(0,e.useState)([]),[W,H]=(0,e.useState)(null),[J,z]=(0,e.useState)(!1),G=Boolean(CLAWPRESS_PANEL.mockEnabled),[K,X]=(0,e.useState)(localStorage.getItem("clawpress_theme")||"light"),[V,Z]=(0,e.useState)("normal"),[Y,Q]=(0,e.useState)("normal"),[ee,te]=(0,e.useState)(!1),se=(0,e.useRef)(null),re=(0,e.useRef)(0),ae=(0,e.useRef)([]),ne=(0,e.useRef)(!1),le=(0,e.useRef)(null),oe=(0,e.useRef)(null),ie=(0,e.useRef)(!1),ce=(0,e.useRef)(null),ue=(0,e.useRef)(null),de=(0,e.useRef)(null),pe=(0,e.useRef)(null),me=(0,e.useRef)(v),ge=(0,e.useRef)(C);(0,e.useEffect)(()=>{me.current=v},[v]),(0,e.useEffect)(()=>{},[]),(0,e.useEffect)(()=>{ge.current=C},[C]);const he=(0,e.useRef)(!1),we=(0,e.useRef)(0),fe=(0,e.useRef)(i),ye=e=>{if(!e||"object"!=typeof e)return null;const t="string"==typeof e.type?e.type.trim():"";return t?{type:t,data:e.data&&"object"==typeof e.data&&!Array.isArray(e.data)?e.data:{}}:null},_e=(e,t,s=null)=>d(r=>[...r,{id:`msg-${Date.now()}-${Math.random()}`,role:e,content:t,card:ye(s),createdAt:++re.current}]),be=(e,s=null,r=null)=>{if(!e||"object"!=typeof e)return"";const a="string"==typeof e.name?e.name.trim():"";if(!a)return"";const n="string"==typeof e.status?e.status.trim().toLowerCase():"success",l="string"==typeof e.message?e.message.trim():"";let o=(0,t.__)("success","clawpress");"error"===n?o=(0,t.__)("error","clawpress"):"requires_confirmation"===n&&(o=(0,t.__)("confirmation required","clawpress"));let i=(0,t.sprintf)(/* translators: 1: tool name, 2: tool call status */ /* translators: 1: tool name, 2: tool call status */ +(0,t.__)("Tool call `%1$s` (%2$s)","clawpress"),a,o);return Number.isFinite(Number(s))&&Number.isFinite(Number(r))&&(i=(0,t.sprintf)(/* translators: 1: tool call position, 2: total tool calls, 3: tool summary text */ /* translators: 1: tool call position, 2: total tool calls, 3: tool summary text */ +(0,t.__)("[%1$d/%2$d] %3$s","clawpress"),Math.max(1,Math.round(Number(s))),Math.max(1,Math.round(Number(r))),i)),l?`${i}\n${l}`:i},xe=e=>{if(!Array.isArray(e))return[];const t=new Set;return e.map(e=>"string"==typeof e?e.trim():"").filter(e=>e.length>0).filter(e=>!t.has(e)&&(t.add(e),!0)).slice(0,8)},Ne=(0,e.useRef)(null),ve=e=>{const t="string"==typeof e&&e.trim()?e:"";if(!t)return;const s=Ne.current;if(s)return void d(e=>e.map(e=>e.id===s?{...e,content:t}:e));const r=`status-${Date.now()}-${Math.random()}`;Ne.current=r,d(e=>[...e,{id:r,role:"system",content:t,createdAt:++re.current}])},je=()=>{const e=Ne.current;e&&(d(t=>t.filter(t=>t.id!==e)),Ne.current=null)};(0,e.useEffect)(()=>localStorage.setItem("clawpress_open",JSON.stringify(r)),[r]),(0,e.useEffect)(()=>localStorage.setItem("clawpress_width",String(i)),[i]),(0,e.useEffect)(()=>{document.body.classList.toggle("clawpress-panel-open",r),document.body.style.setProperty("--clawpress-panel-width",`${i}px`);const e=document.querySelector("#wp-admin-bar-clawpress-toggle > a");e&&e.setAttribute("aria-expanded",r?"true":"false")},[r,i]),(0,e.useEffect)(()=>{localStorage.setItem("clawpress_theme",K)},[K]),(0,e.useEffect)(()=>{localStorage.setItem("clawpress_input_history",JSON.stringify(h))},[h]),(0,e.useEffect)(()=>{const e=e=>{if(!e.metaKey&&!e.ctrlKey||e.altKey||"k"!==e.key.toLowerCase())return;const t=e.target?.ownerDocument?.activeElement,s=t?.tagName?.toLowerCase();"input"===s||"textarea"===s||"select"===s||t?.isContentEditable||(e.preventDefault(),n(e=>!e))};return window.addEventListener("keydown",e),()=>window.removeEventListener("keydown",e)},[]),(0,e.useEffect)(()=>{const e=e=>{if(!he.current)return;const t=we.current-e.clientX,s=Math.max(320,fe.current+t);c(s)},t=()=>{he.current&&(he.current=!1,document.body.classList.remove("clawpress-resizing"))};return window.addEventListener("mousemove",e),window.addEventListener("mouseup",t),()=>{window.removeEventListener("mousemove",e),window.removeEventListener("mouseup",t)}},[]);const ke=(e=null)=>{const t="string"==typeof e?e.trim():p.trim();t&&(g(""),w(e=>{const s=[...e,t],r=CLAWPRESS_PANEL.historyLimit??20;return s.length>r?s.slice(s.length-r):s}),y(-1),b(""),_e("user",t),E(!0),$e(t))},Se=e=>m(e).concurrency||"parallel",Ae=(e,t=[])=>{const s=e?.name||"",r="serial"===Se(s)&&t.some(e=>e.function?.name===s&&"done"!==e.status&&"error"!==e.status&&"cancelled"!==e.status);return{id:`tool-${Date.now()}-${Math.random()}`,function:e,args:o(e),status:r?"blocked":"idle",result:null,error:null,diff:null,createdAt:++re.current}},Ee=()=>{if(ne.current)return;const e=ae.current;if(!e.length)return;const{type:s,payload:r}=e.shift();"assistant_message"===s&&r?.content?(e=>{if(!e)return;ne.current=!0,j(""),me.current="";let t=0;const s=()=>{if(t>=e.length)return _e("assistant",e),j(""),me.current="",ne.current=!1,le.current=null,void Ee();const r=e.slice(t,t+2);t+=2,j(e=>{const t=e+r;return me.current=t,t}),le.current=setTimeout(s,30)};s()})(r.content):(((e,s)=>{switch(e){case"delta":if(je(),s.text){const e=`${me.current||""}${s.text}`;me.current=e,j(e)}break;case"response_message":je(),s?.text&&_e("system"===s?.role?"system":"assistant",s.text);break;case"response_card":je(),s?.card&&_e("system"===s?.role?"system":"assistant","string"==typeof s?.text?s.text:"",s.card);break;case"history_reset":je(),d([]),D([]),R([]),H(null),re.current=0;break;case"suggestions":{const e=xe(s?.items);U(e);break}case"context_usage":s?.context&&H(s.context);break;case"tool_call":if(s?.call&&"object"==typeof s.call){const e=be(s.call,s?.index,s?.total);e&&_e("system",e);break}P||(ve((0,t.__)("Preparing tool plan…","clawpress")),q(!0));break;case"tool_plan":s?.function&&"object"==typeof s.function&&R(e=>[...e,s]),q(!1);break;case"run_progress":ve("string"==typeof s?.text&&s.text.trim()?s.text:(0,t.__)("I am still working on this","clawpress"));break;case"error":je(),_e("system",s?.error||(0,t.__)("Stream error.","clawpress"),s?.card||((e,s="")=>{const r={timeout:(0,t.__)("Request timed out","clawpress"),request:(0,t.__)("Network or API request error","clawpress"),provider:(0,t.__)("Provider error","clawpress")},a="string"==typeof s&&r[s]?r[s]:(0,t.__)("Error","clawpress");return{type:"error",data:{title:(0,t.__)("Request Error","clawpress"),subtitle:a,message:"string"==typeof e&&e.trim()?e:(0,t.__)("Chat request failed.","clawpress")}}})(s?.error,s?.type));break;case"done":je(),(()=>{N(!1),se.current=null,E(!1),je();const e=me.current;e&&e.trim()?(_e("assistant",e),j(""),me.current=""):(j(""),me.current="");const t=Array.isArray(ge.current)?ge.current:[];t.length>0&&D(e=>{const s=[...e];return t.forEach(e=>{e?.function&&"object"==typeof e.function&&s.push(Ae(e.function,s))}),s}),R([]),Me(!0)})()}})(s,r),Ee())},Ce=(e,t)=>{E(!1),ae.current.push({type:e,payload:t}),Ee()},Re=()=>M({mockEnabled:G,mockScenario:V,mockDelay:Y,restBase:CLAWPRESS_PANEL.restBase,streamNonce:CLAWPRESS_PANEL.streamNonce,nonce:CLAWPRESS_PANEL.nonce,onEvent:Ce,onDone:()=>Ce("done",{}),onError:e=>Ce("error",e)}),Me=async(e=!1)=>{e||O(!0);try{const e=await(Re().getStatus?.());e&&(L(e),U(t=>t.length>0?t:xe(e?.suggestions)))}catch{L(null)}finally{O(!1)}};ce.current=_e,ue.current=e=>Array.isArray(e)?e.filter(e=>e&&"object"==typeof e).reduce((e,t,s)=>{const r="user"===t.role||"assistant"===t.role||"system"===t.role?t.role:"system",a="string"==typeof t.content?t.content:"",n=Number.isFinite(Number(t.createdAt))?Number(t.createdAt):s+1,l="string"==typeof t.id&&t.id?t.id:`history-${n}-${s}`,o=ye(t.card),i=Array.isArray(t.tool_calls)?t.tool_calls.filter(e=>e&&"object"==typeof e):[];return i.forEach((t,s)=>{const r=be(t,s+1,i.length);r&&e.push({id:`${l}-tool-${s+1}`,role:"system",content:r,card:null,createdAt:n})}),e.push({id:l,role:r,content:a,card:o,createdAt:n}),e},[]):[],de.current=Re,pe.current=Me,(0,e.useEffect)(()=>{let e=!0;return(async()=>{const s=de.current?.();if(s){try{const t=await(s.getPanelState?.());if(!e)return;const a=(r=t)&&"object"==typeof r?{open:"boolean"==typeof r.open?r.open:null,width:Number.isFinite(Number(r.width))?Number(r.width):null,lastHistoryId:"string"==typeof r.last_history_id?r.last_history_id:"",welcomeCardSeen:"boolean"==typeof r.welcome_card_seen&&r.welcome_card_seen}:{open:null,width:null,lastHistoryId:"",welcomeCardSeen:!1};"boolean"==typeof a.open&&n(a.open),Number.isFinite(a.width)&&a.width>0&&c(a.width),ie.current=!0===a.welcomeCardSeen}catch{}var r;try{const t=await(s.getStatus?.());if(!e)return;L(t||null),U(e=>e.length>0?e:xe(t?.suggestions))}catch{if(!e)return;L(null)}finally{e&&O(!1)}try{const r=await(s.getHistory?.());if(!e)return;const a=ue.current?.(r?.items||[])||[];if(0!==a.length||ie.current)d(a),re.current=a.reduce((e,t)=>Math.max(e,Number(t.createdAt)||0),0);else{const e=Date.now(),r={id:`welcome-${e}`,role:"assistant",content:"",card:{type:"welcome",data:{title:(0,t.__)("Welcome to ClawPress","clawpress"),message:(0,t.__)("Hello! I am ready to help with your WordPress tasks.","clawpress"),emoji:"👋",actions:[{id:"start-setup",label:(0,t.__)("Start Setup","clawpress"),prompt:"/setup start"}]}},createdAt:e};d([r]),re.current=e,s.setPanelState?.({welcome_card_seen:!0}).catch(()=>{})}}catch{if(!e)return;ce.current?.("system",(0,t.__)("Unable to load chat history.","clawpress"))}e&&z(!0)}})(),()=>{e=!1}},[]),(0,e.useEffect)(()=>{if(!r)return;pe.current?.(!0);const e=setInterval(()=>{pe.current?.(!0)},15e3);return()=>clearInterval(e)},[r]),(0,e.useEffect)(()=>{if(!J)return;const e=u[u.length-1],t={open:r,width:i,last_history_id:"string"==typeof e?.id?e.id:""};return oe.current&&clearTimeout(oe.current),oe.current=setTimeout(()=>{const e=de.current?.();e?.setPanelState?.(t).catch(()=>{})},350),()=>{oe.current&&clearTimeout(oe.current)}},[J,r,i,u]);const $e=async e=>{N(!0),j(""),me.current="",R([]);const t=Re();se.current=t.stream(e)},Te=(e,t)=>{D(s=>s.map(s=>s.id===e?{...s,...t}:s))};return(0,e.useEffect)(()=>{D(e=>{let t=!1;const s=e.map(s=>{const r=s.function?.name||"";if("serial"!==Se(r))return s;if("running"===s.status||"error"===s.status||"done"===s.status||"cancelled"===s.status)return s;const a=e.some(e=>e.function?.name===r&&e.createdAt{const e=document.querySelector("#wp-admin-bar-clawpress-toggle > a");if(!e)return void te(!0);te(!1);const t=e=>{e.preventDefault(),n(e=>!e)};return e.addEventListener("click",t),()=>e.removeEventListener("click",t)},[]),(0,s.jsxs)(e.Fragment,{children:[ee?(0,s.jsx)(S,{onToggle:()=>n(e=>!e)}):null,(0,s.jsxs)("div",{className:"clawpress-panel","data-theme":K,style:{width:`${i}px`},children:[(0,s.jsx)(a,{onClose:()=>n(!1),onToggleTheme:()=>X(e=>"light"===e?"dark":"light"),statusMode:I?.mode||"offline",statusLabel:(e=>{if(!e||"object"!=typeof e)return"";const t=e?.provider?.id,s=e?.model?.id;return t&&s?`${t} · ${s}`:t||""})(I),statusLoading:F}),(0,s.jsx)("button",{type:"button",className:"clawpress-drag-handle",onMouseDown:e=>{he.current=!0,we.current=e.clientX,fe.current=i,document.body.classList.add("clawpress-resizing")},"aria-label":(0,t.__)("Resize panel","clawpress")}),(0,s.jsx)(k,{messages:u,streaming:x,currentStreamText:v,waitingForResponse:A,toolDialogs:T,onRunToolDialog:(e,s)=>{const r=T.find(t=>t.id===e);if(!r)return;const a=r.function?.name||"";if("serial"===Se(a)){const e=T.filter(e=>e.function?.name===a).sort((e,t)=>e.createdAt-t.createdAt),t=e.some(e=>"running"===e.status),n=e.some(e=>e.id!==r.id&&e.createdAt{const s=e.function?.name||"",r=e.args||o(e.function);Te(e.id,{status:"running",error:null});try{const t=await(async(e,t)=>{try{return await Re().runTool(e,t)}catch(e){throw e}})(s,r),a=t.step?.data?.result??null,n=Array.isArray(a?.changed)?a.changed:null;Te(e.id,{status:"done",result:a,diff:n})}catch(s){Te(e.id,{status:"error",error:s?.message||(0,t.__)("Tool execution failed.","clawpress")})}})(s?{...r,args:s}:r)},onCancelToolDialog:e=>{Te(e,{status:"cancelled"})},onSendCardAction:e=>{if("string"==typeof e)return void ke(e);if(!e||"object"!=typeof e)return;if("open_url"===e.type){const s="string"==typeof e.url?e.url.trim():"";if(!s)return void _e("system",(0,t.__)("Invalid card action.","clawpress"));try{const e=new URL(s,window.location.origin);window.location.assign(e.toString())}catch{_e("system",(0,t.__)("Invalid card URL.","clawpress"))}return}if("run_tool"===e.type){const s="string"==typeof e.tool?e.tool.trim():"";if(!s)return void _e("system",(0,t.__)("Invalid card action.","clawpress"));const r=e.args&&"object"==typeof e.args&&!Array.isArray(e.args)?e.args:{};return void D(e=>{const t=[...e];return t.push(Ae({name:s,arguments:r},t)),t})}const s="string"==typeof e.prompt?e.prompt.trim():"";s&&ke(s)}}),(0,s.jsx)(l,{input:p,onInputChange:e=>g(e.target.value),onSend:ke,panelOpen:r,suggestions:B,contextUsage:W,onSendSuggestion:e=>ke(e),onStop:()=>{se.current?.stop?.(),_e("system",(0,t.__)("Stream stopped.","clawpress"))},streaming:x,onHistoryUp:e=>{if(0===h.length)return null;if(-1===f){b(e);const t=h.length-1;return y(t),h[t]}const t=Math.max(0,f-1);return y(t),h[t]},onHistoryDown:()=>{if(-1===f)return null;const e=f+1;if(e>=h.length){y(-1);const e=_;return b(""),e}return y(e),h[e]}})]}),(0,s.jsx)($,{mockEnabled:G,mockScenario:V,mockDelay:Y,onSelectScenario:Z,onSelectDelay:Q,onRunScenario:()=>{if(!G)return;g("");const e=(0,t.sprintf)(/* translators: %s: selected mock scenario */ /* translators: %s: selected mock scenario */ +(0,t.__)("Mock: %s","clawpress"),V);_e("user",e),$e(e)},themeMode:K})]})};let D=document.getElementById("clawpress-floating-panel-root");D||(D=document.createElement("div"),D.id="clawpress-floating-panel-root",document.body.appendChild(D)),(0,e.createRoot)(D).render((0,s.jsx)(T,{}))})(); \ No newline at end of file diff --git a/clawpress.php b/clawpress.php index 8bf545c..b58aead 100644 --- a/clawpress.php +++ b/clawpress.php @@ -1,9 +1,12 @@ `Agent_Loop_Helper::run_slice`) + +1. Resolve provider/model. +2. Create and claim chat session/run records up front (`run_id`, `session_id`). +3. Build runtime request payload. +4. Execute a bounded slice through `Agent_Loop_Helper::run_slice`. +5. Persist first-slice outcomes onto the same run/session state. +6. Emit run-linked events from the first slice (no pre-run event gap). +7. If slice completes: return assistant text/card/context/tool-call trace. +8. If slice returns `in_progress`: persist `resume_cursor`, enqueue background continuation, and return `run_id` + `session_id` + `status=in_progress` for polling. + +If provider is unavailable, it returns deterministic offline mode without background run creation. + +Execution identity: + +- Chat, run-creation, and spawn adapters resolve execution user consistently. +- Preferred execution user is configured `agent_user_id`; fallback is requesting user. + +## 2) Background Path (`Agent_Run_Controller` + `Agent_Runner`) + +1. `POST /clawpress/v1/agent/runs` creates session/run and enqueues slice action. +2. Runner claims runnable run (`queued`/`paused`, retry time reached). + - Runnable scan also includes stale `running` rows whose run lock lease has expired. +3. Runner claims session lease. +4. Runner executes one slice via `Agent_Loop_Helper::run_slice`. +5. Runner either: + - completes run (`done`, `requires_confirmation`, `error`, etc.), or + - pauses run with `resume_cursor` and `next_retry_at_gmt`, then re-enqueues. + +## 3) Spawn Path (`POST /agent/spawn`) + +1. Create a new `spawned_agent` session. +2. Seed first run metadata (`message`, slice budgets, retry limits). +3. Enqueue first slice. +4. Return `session_id` + `run_id` immediately. + +## Locking and Claim Rules + +### Run claim + +`Agent_Run_Helper::claim_run` supports: + +- fresh claim from `queued` (attempt stays at current value; first claim remains `1`), +- retry claim from `paused` (attempt increments), +- stale reclaim from expired `running` lease (attempt increments). + +Heartbeat scan intentionally includes expired `running` leases so stale claims are actually discoverable in production. + +### Session claim + +`Agent_Session_Helper::claim_session` supports: + +- `idle` and `paused` sessions, +- stale reclaim of expired `running` lease. + +This prevents deadlock when a run pauses and later needs to resume under a paused session. + +## Retry and Failure Semantics + +Session failure counter behavior (`update_run_completion`): + +- reset to `0` for `success`, `done`, `requires_confirmation`, +- unchanged for `paused`, +- incremented for error-like outcomes. + +This keeps time-slicing and confirmation waits from being treated as failures. + +Retry model notes: + +- `attempt` tracks claim lifecycle visibility. +- `retry_count` tracks error-driven retries/backoff only. +- continuation pauses (`in_progress`) do not increment `retry_count`. +- paused progress releases run lock metadata (`lock_token`, `claimed_by`, lease timestamps) because lock scope is one slice. +- runner enforces trigger policy `max_wall_time_seconds` and marks run `timeout` when exceeded. +- when `allow_background_followups` is false, the runner does not enqueue additional slices after `in_progress` or retryable error outcomes. + +Policy violation modes: + +- `deny`: tool call is rejected with policy error payload. +- `degrade`: tool call returns a successful degraded no-op payload so the model can continue. +- `fail`: tool call returns an error payload tagged as fail mode (`*_fail` policy code). + +## Manual Re-enqueue Safety + +`POST /agent/runs/{run_id}/enqueue` is intentionally guarded: + +- only terminal/retryable statuses can be re-enqueued, +- active statuses (`queued`, `running`, `paused`) return HTTP `409`, +- DB update is compare-and-swap on `id + status` to avoid racey resets. + +This prevents accidental mutation of active runs and stale-state rewrites. + +## Idempotency + +`idempotency_key` is supported on run creation: + +- `POST /agent/runs` with the same `session_id + idempotency_key` returns the existing run instead of inserting a duplicate. +- duplicate create requests return HTTP `200` with `deduplicated=true`. +- unknown explicit `session_id` now returns HTTP `404` to prevent orphan run creation. +- run table enforces unique `(session_id, idempotency_key)` for non-null keys to harden concurrent create races. +- helper fallback re-reads by idempotency key when insert returns no ID, covering duplicate-key race windows. + +## Agent Loop Callback Compatibility + +`Agent_Loop_Helper` supports custom `online_reply_generator` callables with varying arity. + +Arity resolver supports: + +- array callables (`[object, 'method']`), +- closures/string callables, +- invokable objects (`__invoke`). + +Invokable object reflection uses `ReflectionMethod(__invoke)` and catches both `ReflectionException` and `TypeError`. + +## REST Endpoints + +`Agent_Run_Controller` registers: + +- `POST /clawpress/v1/agent/runs` +- `POST /clawpress/v1/agent/spawn` +- `POST /clawpress/v1/agent/runs/{run_id}/enqueue` +- `GET /clawpress/v1/agent/runs/{run_id}` +- `GET /clawpress/v1/agent/runs/{run_id}/events?after={event_id}&limit={n}` + +## Eventing + +- `Polling_Transport` appends runtime events via `Agent_Event_Helper`. +- Runner emits operational events (slice started/paused/completed). +- Polling endpoint returns incremental cursor (`next_cursor`) for client-side streaming. + +## Test Harness Coverage + +The runtime harness uses `tests/Support/AgentRuntimeWpdb.php` and covers: + +- run/session claim and completion lifecycle, +- paused-session reclaim behavior, +- attempt accounting correctness, +- enqueue safety rules, +- paused and requires-confirmation failure accounting, +- runner progression from queued run to completion, +- runner missing-session and unclaimable-session negative flows, +- controller create/enqueue/events behavior, +- controller idempotency and unknown-session negative flows, +- spawn endpoint flow, +- chat `in_progress` handoff with run metadata, +- loop invocation with invokable callback arity, +- loop streaming-mode transport fallback and slice `in_progress` payload semantics. + +Primary tests: + +- `tests/Unit/AgentRunHelperTest.php` +- `tests/Unit/AgentRunnerTest.php` +- `tests/Unit/AgentRunControllerTest.php` +- `tests/Unit/AgentLoopHelperTest.php` + +## Operational Notes + +- `Agent_Runner` is wired in plugin bootstrap and listens to `clawpress_run_scheduled_tasks` and `clawpress_agent_run_slice`. +- Action Scheduler async actions are preferred; delayed slices use single scheduled actions. +- Chat and REST controllers delegate run-slice scheduling to `Agent_Runner::enqueue_run_slice_action()` to avoid duplicate enqueue behavior. +- Keep helper/store boundaries intact: controllers and runner call helpers; helpers call stores. diff --git a/docs/lint-baseline.md b/docs/lint-baseline.md deleted file mode 100644 index 9e00edd..0000000 --- a/docs/lint-baseline.md +++ /dev/null @@ -1,10 +0,0 @@ -# Lint baseline (placeholder) - -Create this baseline after running full lint and collecting current violations. - -Suggested format: - -- PHPCS: `` -- JS lint: `` -- Date captured: `` -- Owner: `` diff --git a/docs/lint-debt-reduction-plan.md b/docs/lint-debt-reduction-plan.md deleted file mode 100644 index 5b17b4a..0000000 --- a/docs/lint-debt-reduction-plan.md +++ /dev/null @@ -1,35 +0,0 @@ -# Lint debt reduction plan - -This project will enforce lint in phases so contributors can keep shipping while we burn down legacy violations. - -## Phase 1 (now): changed-file lint gate - -- CI runs `lint-changed` on every PR. -- Only files touched in the PR are lint-gated. -- Goal: no new lint debt. - -## Phase 2 (by 2026-03-15): baseline capture - -- Capture current full-repo lint violations into a baseline record (`docs/lint-baseline.md`). -- Categorize by type: - - PHPCS - - JS lint -- Track counts and owners. - -## Phase 3 (2026-03-15 to 2026-05-15): burn-down - -Milestones: - -1. **M1 (2026-03-31)**: reduce baseline by 25% -2. **M2 (2026-04-30)**: reduce baseline by 60% -3. **M3 (2026-05-15)**: reduce baseline by 90%+ - -## Phase 4 (target: 2026-06-01): full strict lint - -- Switch CI from changed-file lint to full-repo strict lint as required checks. -- Keep `lint-changed` available for fast local feedback. - -## Commands - -- Changed files: `npm run lint:changed` -- Full lint: `npm run lint` diff --git a/docs/policy-helper.md b/docs/policy-helper.md index da2fad8..2357cac 100644 --- a/docs/policy-helper.md +++ b/docs/policy-helper.md @@ -25,7 +25,7 @@ Implementation: `includes/helpers/class-policy-helper.php` 2. Performing permission checks. 3. Enforcing all policy fields itself. -Enforcement is done by consumers such as `Abilities_Helper` and `Chat_Helper`. +Enforcement is done by consumers such as `Agent_Loop_Helper`, `Abilities_Helper`, and `Agent_Runner`. ## Runtime Policy Contract @@ -92,15 +92,15 @@ Restrictive but less constrained than heartbeat: ## Where It Is Used -## 1) Chat loop policy resolution +## 1) Loop policy resolution -`Chat_Helper::generate_online_reply()` resolves policy once per model run and then uses it to bound loop execution: +`Agent_Loop_Helper::generate_online_reply()` resolves policy once per turn/slice and then uses it to bound loop execution: 1. `max_tool_rounds` controls tool-call rounds. 2. `max_tool_calls_per_round` caps calls per round. 3. The resolved policy is passed into each `Abilities_Helper::execute_tool_call()` invocation as `runtime_policy`. -Implementation: `includes/helpers/class-chat-helper.php` +Implementation: `includes/helpers/class-agent-loop-helper.php` ## 2) Tool execution policy enforcement @@ -119,6 +119,15 @@ On policy violation, it returns a structured payload with: Implementation: `includes/helpers/class-abilities-helper.php` +## 3) Runner-level policy enforcement + +`Agent_Runner::process_claimed_run()` enforces run-lifecycle guardrails: + +1. `max_wall_time_seconds` is enforced before and after slice execution. +2. `allow_background_followups` gates re-enqueue behavior for both continuation (`in_progress`) and retryable error outcomes. + +Implementation: `includes/class-agent-runner.php` + ## Practical Usage Patterns ## Let helper resolve from trigger + overrides @@ -181,22 +190,18 @@ $result = Abilities_Helper::get_instance()->execute_tool_call( - override behavior - enforcement behavior -## Extend policy violation handling - -`on_policy_violation` is currently normalized and returned in violation payload metadata. If richer behavior is needed (`degrade`, `fail` semantics), implement that behavior in enforcement consumers, not in the helper. +## Policy violation handling -Recommended pattern: +`on_policy_violation` is normalized in `Policy_Helper` and enforced in `Abilities_Helper`: -1. Keep helper deterministic and side-effect free. -2. Implement runtime action semantics where the violation occurs. -3. Maintain structured, machine-readable violation payloads. +1. `deny`: returns policy error payload. +2. `degrade`: returns successful degraded no-op payload. +3. `fail`: returns policy error payload with `*_fail` code suffix. ## Suggested Future Improvements -1. Enforce `max_wall_time_seconds` in `Chat_Helper` loop using elapsed wall-clock checks. -2. Wire `allow_network` to network-capable tools or provider request constraints. -3. Use `allow_background_followups` in scheduler/spawn logic. -4. Add a policy filter hook (for example, `clawpress_runtime_policy_resolved`) if third-party plugins need policy customization without patching core code. +1. Wire `allow_network` to network-capable tools or provider request constraints. +2. Add a policy filter hook (for example, `clawpress_runtime_policy_resolved`) if third-party plugins need policy customization without patching core code. ## Test Coverage Today @@ -210,4 +215,3 @@ Recommended additional coverage: 1. Chat loop behavior under non-default `max_tool_rounds` and `max_tool_calls_per_round`. 2. Any new enforcement path added for currently informational fields. - diff --git a/docs/streaming-support.md b/docs/streaming-support.md new file mode 100644 index 0000000..b16d358 --- /dev/null +++ b/docs/streaming-support.md @@ -0,0 +1,91 @@ +# Streaming Support Plan + +This document explains how ClawPress will adopt true streaming once the WP AI Client exposes stable streaming APIs. + +## Current State + +ClawPress already has the runtime layering needed for streaming with minimal core changes: + +- Transport contract: `includes/transports/interface-agent-transport.php` +- Polling transport: `includes/transports/class-polling-transport.php` +- Null transport: `includes/transports/class-null-transport.php` +- Loop runtime integration point: `includes/helpers/class-agent-loop-helper.php` + +Today, `transport_mode=streaming` is accepted but intentionally routed through polling transport in `Agent_Loop_Helper::create_transport()`. This means there is no true live token streaming yet. + +## Why This Is Low-Risk + +The execution control plane is already decoupled from delivery transport: + +- Run/session state machine lives in helpers/stores. +- Runner logic (claim, pause, retry, complete) is transport-agnostic. +- Event persistence and polling APIs remain valid as fallback. + +As a result, adding streaming should not require redesigning retries, leases, resumability, or DB schemas. + +## Minimal Changes Needed When WP AI Client Adds Streaming + +1. Add `Streaming_Transport` implementation. + - New file: `includes/transports/class-streaming-transport.php` + - Implement `Agent_Transport` (`emit()`, `close()`). + - Emit live deltas to the connected client channel (SSE/WebSocket). + - Optionally mirror selected events to `Agent_Event_Helper` for observability and fallback polling. + +2. Switch transport selection in loop runtime. + - Update `Agent_Loop_Helper::create_transport()` in `includes/helpers/class-agent-loop-helper.php`. + - Return `Streaming_Transport` for `transport_mode=streaming`. + - Keep polling as default/fallback. + +3. Integrate streaming model-call path. + - Update model invocation in `includes/helpers/class-agent-loop-helper.php`. + - Consume WP AI Client chunk/delta callbacks. + - Emit incremental `agent.llm.delta`/`agent.llm.response` style events through transport. + - Preserve existing normalized `TurnResult` semantics at stream end. + +4. Add/extend delivery adapter endpoint. + - Add SSE/WebSocket endpoint in REST/controller layer for clients to subscribe to stream events. + - Keep `/agent/runs/{run_id}/events` polling endpoint as backup path. + +## Components Expected To Stay Unchanged + +These should not need structural changes: + +- `includes/class-agent-runner.php` +- `includes/helpers/class-agent-run-helper.php` +- `includes/helpers/class-agent-session-helper.php` +- `includes/stores/class-agent-run-store.php` +- `includes/stores/class-agent-session-store.php` +- `includes/stores/class-agent-event-store.php` +- `includes/rest/class-agent-run-controller.php` (except optional stream bootstrap helpers) + +## Suggested Event Contract for Streaming + +Recommended event types for live transport: + +- `agent.run.started` +- `agent.llm.delta` +- `agent.llm.response` +- `agent.tool_call` +- `agent.confirmation.required` +- `agent.slice.paused` +- `agent.run.finished` +- `agent.run.error` + +The final event should include enough metadata for the client to reconcile against run status polling. + +## Backward Compatibility Expectations + +- Existing polling clients continue to work without changes. +- `transport_mode=polling` behavior remains unchanged. +- If streaming channel fails, runtime can degrade to persisted polling events. + +## Testing Guidance for Future Streaming Work + +When implementing true streaming, add tests for: + +- transport selection (`streaming` -> `Streaming_Transport`); +- chunk/delta emission ordering and finalization; +- fallback behavior when streaming channel disconnects; +- parity of final `TurnResult` fields between polling and streaming modes; +- runner/background behavior unaffected by streaming transport. + diff --git a/includes/class-agent-runner.php b/includes/class-agent-runner.php new file mode 100644 index 0000000..efb8445 --- /dev/null +++ b/includes/class-agent-runner.php @@ -0,0 +1,552 @@ +run_helper = Agent_Run_Helper::get_instance(); + $this->session_helper = Agent_Session_Helper::get_instance(); + $this->event_helper = Agent_Event_Helper::get_instance(); + $this->loop_helper = Agent_Loop_Helper::get_instance(); + $this->policy_helper = Policy_Helper::get_instance(); + $this->chat_history_helper = Chat_History_Helper::get_instance(); + + add_action( 'clawpress_run_scheduled_tasks', [ $this, 'run_scheduled_tasks' ] ); + add_action( self::RUN_SLICE_ACTION_HOOK, [ $this, 'run_slice_action' ], 10, 1 ); + } + + /** + * Process runnable runs on heartbeat tick. + */ + public function run_scheduled_tasks(): void { + $worker_id = $this->build_worker_id( 'heartbeat' ); + + for ( $index = 0; $index < self::MAX_RUNS_PER_TICK; ++$index ) { + $claim = $this->run_helper->claim_next_runnable_run( $worker_id, self::RUN_LEASE_TTL ); + if ( empty( $claim['claimed'] ) ) { + break; + } + + $this->process_claimed_run( $claim, $worker_id ); + } + } + + /** + * Process one asynchronously queued run. + * + * @param int|array|string $run_id Run id or args payload. + */ + public function run_slice_action( $run_id = 0 ): void { + $resolved_run_id = 0; + if ( is_array( $run_id ) && isset( $run_id['run_id'] ) ) { + $resolved_run_id = (int) $run_id['run_id']; + } else { + $resolved_run_id = (int) $run_id; + } + + if ( $resolved_run_id <= 0 ) { + $this->run_scheduled_tasks(); + return; + } + + $worker_id = $this->build_worker_id( 'slice' ); + $claim = $this->run_helper->claim_run( $resolved_run_id, $worker_id, self::RUN_LEASE_TTL ); + if ( empty( $claim['claimed'] ) ) { + return; + } + + $this->process_claimed_run( $claim, $worker_id ); + } + + /** + * Queue one run slice action. + * + * @param int $run_id Run identifier. + * @param int $delay_seconds Delay in seconds. + */ + public function enqueue_run_slice( int $run_id, int $delay_seconds = 0 ): void { + self::enqueue_run_slice_action( $run_id, $delay_seconds ); + } + + /** + * Queue one run slice action through Action Scheduler. + * + * @param int $run_id Run identifier. + * @param int $delay_seconds Delay in seconds. + */ + public static function enqueue_run_slice_action( int $run_id, int $delay_seconds = 0 ): void { + if ( $run_id <= 0 ) { + return; + } + + if ( function_exists( 'as_enqueue_async_action' ) && $delay_seconds <= 0 ) { + as_enqueue_async_action( + self::RUN_SLICE_ACTION_HOOK, + [ 'run_id' => $run_id ], + self::ACTION_GROUP + ); + return; + } + + if ( function_exists( 'as_schedule_single_action' ) ) { + as_schedule_single_action( + time() + max( 0, $delay_seconds ), + self::RUN_SLICE_ACTION_HOOK, + [ 'run_id' => $run_id ], + self::ACTION_GROUP + ); + } + } + + /** + * Execute one claimed run slice. + * + * @param array $claim Claim payload. + * @param string $worker_id Worker id. + */ + private function process_claimed_run( array $claim, string $worker_id ): void { + $run_id = isset( $claim['run_id'] ) ? (int) $claim['run_id'] : 0; + $lock_token = isset( $claim['lock_token'] ) ? (string) $claim['lock_token'] : ''; + if ( $run_id <= 0 || '' === $lock_token ) { + return; + } + + $run = $this->run_helper->get_run( $run_id ); + if ( [] === $run ) { + return; + } + + $session_id = isset( $run['session_id'] ) ? (int) $run['session_id'] : 0; + $session = $this->session_helper->get_session( $session_id ); + if ( $session_id <= 0 || [] === $session ) { + $this->run_helper->complete_run( + $run_id, + $lock_token, + 'error', + [ + 'error_code' => 'session_missing', + 'error_message' => __( 'Run session could not be loaded.', 'clawpress' ), + ] + ); + return; + } + + $runtime_policy = $this->policy_helper->resolve_runtime_policy( + isset( $run['trigger_type'] ) ? (string) $run['trigger_type'] : 'heartbeat', + [ + 'policy_profile' => isset( $session['policy_profile'] ) ? (string) $session['policy_profile'] : 'default', + ] + ); + $max_wall_time_seconds = isset( $runtime_policy['max_wall_time_seconds'] ) + ? max( 1, (int) $runtime_policy['max_wall_time_seconds'] ) + : 120; + $allow_background_followups = isset( $runtime_policy['allow_background_followups'] ) && true === $runtime_policy['allow_background_followups']; + + $session_claim = $this->session_helper->claim_session( $session_id, $worker_id, self::SESSION_LEASE_TTL ); + if ( empty( $session_claim['claimed'] ) ) { + $delay_seconds = self::RETRY_BACKOFF_BASE; + $this->run_helper->pause_run( + $run_id, + $lock_token, + [ + 'status' => 'paused', + 'next_retry_at_gmt' => gmdate( 'Y-m-d H:i:s', time() + $delay_seconds ), + 'meta' => [ + 'reason' => 'session_not_claimable', + 'pause_reason' => 'session_not_claimable', + ], + ] + ); + $this->enqueue_run_slice( $run_id, $delay_seconds ); + return; + } + + if ( $this->has_run_exceeded_wall_time( $run, $max_wall_time_seconds ) ) { + $this->run_helper->complete_run( + $run_id, + $lock_token, + 'timeout', + [ + 'error_code' => 'wall_time_exceeded', + 'error_message' => __( 'Run exceeded maximum wall time.', 'clawpress' ), + 'meta' => [ + 'max_wall_time_seconds' => $max_wall_time_seconds, + ], + ] + ); + $this->session_helper->release_session( $session_id, (string) $session_claim['lease_token'], 'error' ); + $this->event_helper->emit( + 'agent.runner.run_timed_out', + [ + 'run_id' => $run_id, + 'session_id' => $session_id, + 'payload' => [ + 'reason' => 'wall_time_exceeded', + 'max_wall_time_seconds' => $max_wall_time_seconds, + ], + ] + ); + return; + } + + $turn_request = [ + 'run_id' => $run_id, + 'session_id' => $session_id, + 'trigger' => isset( $run['trigger_type'] ) ? (string) $run['trigger_type'] : 'heartbeat', + 'transport_mode' => isset( $run['transport_mode'] ) ? (string) $run['transport_mode'] : 'polling', + 'requesting_user_id' => isset( $session['requesting_user_id'] ) ? (int) $session['requesting_user_id'] : 0, + 'execution_user_id' => isset( $session['execution_user_id'] ) ? (int) $session['execution_user_id'] : 0, + 'slice_budget_ms' => isset( $run['meta']['slice_budget_ms'] ) ? (int) $run['meta']['slice_budget_ms'] : 1500, + 'max_steps_per_slice' => isset( $run['meta']['max_steps_per_slice'] ) ? (int) $run['meta']['max_steps_per_slice'] : 1, + 'attempt' => isset( $run['attempt'] ) ? (int) $run['attempt'] : 1, + 'resume_cursor' => isset( $run['resume_cursor'] ) ? $run['resume_cursor'] : null, + 'session_metadata' => [ + 'policy_profile' => isset( $session['policy_profile'] ) ? (string) $session['policy_profile'] : 'default', + ], + 'message' => isset( $run['meta']['message'] ) ? (string) $run['meta']['message'] : '', + ]; + + $this->event_helper->emit( + 'agent.runner.slice_started', + [ + 'run_id' => $run_id, + 'session_id' => $session_id, + 'payload' => [ + 'worker_id' => $worker_id, + 'attempt' => isset( $run['attempt'] ) ? (int) $run['attempt'] : 1, + ], + ] + ); + + $result = $this->loop_helper->run_slice( $turn_request ); + + $status = isset( $result['status'] ) ? (string) $result['status'] : 'error'; + if ( 'in_progress' === $status ) { + if ( $this->has_run_exceeded_wall_time( $run, $max_wall_time_seconds ) ) { + $this->run_helper->complete_run( + $run_id, + $lock_token, + 'timeout', + [ + 'error_code' => 'wall_time_exceeded', + 'error_message' => __( 'Run exceeded maximum wall time.', 'clawpress' ), + 'meta' => [ + 'last_result' => $result, + 'max_wall_time_seconds' => $max_wall_time_seconds, + ], + ] + ); + $this->session_helper->release_session( $session_id, (string) $session_claim['lease_token'], 'error' ); + return; + } + + if ( ! $allow_background_followups ) { + $this->run_helper->complete_run( + $run_id, + $lock_token, + 'timeout', + [ + 'error_code' => 'background_followups_disabled', + 'error_message' => __( 'Background follow-up slices are disabled for this trigger.', 'clawpress' ), + 'meta' => [ + 'last_result' => $result, + ], + ] + ); + $this->session_helper->release_session( $session_id, (string) $session_claim['lease_token'], 'error' ); + return; + } + + $delay_seconds = 0; + $pause_args = [ + 'status' => 'paused', + 'next_retry_at_gmt' => gmdate( 'Y-m-d H:i:s', time() + $delay_seconds ), + 'meta' => [ + 'last_result' => $result, + 'pause_reason' => 'slice_budget', + ], + ]; + if ( array_key_exists( 'resume_cursor', $result ) ) { + $pause_args['resume_cursor'] = $result['resume_cursor']; + } + $this->run_helper->pause_run( $run_id, $lock_token, $pause_args ); + $this->session_helper->release_session( $session_id, (string) $session_claim['lease_token'], 'paused' ); + $this->enqueue_run_slice( $run_id, $delay_seconds ); + + $this->event_helper->emit( + 'agent.runner.slice_paused', + [ + 'run_id' => $run_id, + 'session_id' => $session_id, + 'payload' => [ + 'next_retry_at_gmt' => gmdate( 'Y-m-d H:i:s', time() + $delay_seconds ), + ], + ] + ); + return; + } + + if ( in_array( $status, [ 'success', 'requires_confirmation' ], true ) ) { + $terminal_status = 'success' === $status ? 'done' : 'requires_confirmation'; + $this->run_helper->complete_run( + $run_id, + $lock_token, + $terminal_status, + [ + 'meta' => [ + 'result' => $result, + ], + ] + ); + $this->persist_terminal_chat_result( $run, $session, $result, $terminal_status ); + $this->session_helper->release_session( $session_id, (string) $session_claim['lease_token'], 'idle' ); + $this->event_helper->emit( + 'agent.runner.slice_completed', + [ + 'run_id' => $run_id, + 'session_id' => $session_id, + 'payload' => [ + 'status' => $terminal_status, + ], + ] + ); + return; + } + + $retry_count = isset( $run['retry_count'] ) ? max( 0, (int) $run['retry_count'] ) : 0; + $max_attempts = isset( $run['max_attempts'] ) ? (int) $run['max_attempts'] : 5; + if ( $retry_count < $max_attempts ) { + if ( ! $allow_background_followups ) { + $this->run_helper->complete_run( + $run_id, + $lock_token, + 'timeout', + [ + 'error_code' => 'background_followups_disabled', + 'error_message' => __( 'Background follow-up slices are disabled for this trigger.', 'clawpress' ), + 'meta' => [ + 'last_result' => $result, + ], + ] + ); + $this->session_helper->release_session( $session_id, (string) $session_claim['lease_token'], 'error' ); + return; + } + + $next_retry_count = $retry_count + 1; + $delay_seconds = $this->calculate_retry_backoff( $next_retry_count ); + $pause_args = [ + 'status' => 'paused', + 'next_retry_at_gmt' => gmdate( 'Y-m-d H:i:s', time() + $delay_seconds ), + 'retry_count' => $next_retry_count, + 'meta' => [ + 'last_result' => $result, + 'retry_count' => $next_retry_count, + 'pause_reason' => 'retry_backoff', + ], + ]; + if ( array_key_exists( 'resume_cursor', $result ) ) { + $pause_args['resume_cursor'] = $result['resume_cursor']; + } + $this->run_helper->pause_run( $run_id, $lock_token, $pause_args ); + $this->session_helper->release_session( $session_id, (string) $session_claim['lease_token'], 'paused' ); + $this->enqueue_run_slice( $run_id, $delay_seconds ); + return; + } + + $this->run_helper->complete_run( + $run_id, + $lock_token, + 'error', + [ + 'error_code' => isset( $result['error']['type'] ) ? (string) $result['error']['type'] : 'run_failed', + 'error_message' => isset( $result['error']['message'] ) ? (string) $result['error']['message'] : __( 'Agent run failed.', 'clawpress' ), + 'meta' => [ + 'last_result' => $result, + ], + ] + ); + $this->session_helper->release_session( $session_id, (string) $session_claim['lease_token'], 'error' ); + } + + /** + * Persist terminal chat result back into per-user chat history. + * + * @param array $run Run row. + * @param array $session Session row. + * @param array $result Runtime result payload. + * @param string $terminal_status Terminal run status. + */ + private function persist_terminal_chat_result( array $run, array $session, array $result, string $terminal_status ): void { + $trigger_type = isset( $run['trigger_type'] ) ? sanitize_key( (string) $run['trigger_type'] ) : ''; + if ( 'chat' !== $trigger_type ) { + return; + } + + $requesting_user_id = isset( $session['requesting_user_id'] ) ? (int) $session['requesting_user_id'] : 0; + if ( $requesting_user_id <= 0 ) { + return; + } + + $assistant_text = isset( $result['assistant_text'] ) ? trim( (string) $result['assistant_text'] ) : ''; + $card = isset( $result['card'] ) && is_array( $result['card'] ) ? $result['card'] : null; + $tool_calls = isset( $result['tool_calls'] ) && is_array( $result['tool_calls'] ) ? $result['tool_calls'] : null; + + if ( '' === $assistant_text ) { + if ( null !== $card && isset( $card['data']['message'] ) ) { + $assistant_text = trim( sanitize_text_field( (string) $card['data']['message'] ) ); + } + + if ( '' === $assistant_text ) { + $assistant_text = 'requires_confirmation' === $terminal_status + ? __( 'Action requires confirmation before continuing.', 'clawpress' ) + : __( + 'I finished the background steps, but I did not receive a final text response. Please tell me to continue and I will pick up from here.', + 'clawpress' + ); + } + } + + $this->chat_history_helper->append_history_message( + 'assistant', + $assistant_text, + $card, + $tool_calls, + $requesting_user_id + ); + } + + /** + * Build worker ID. + * + * @param string $suffix Worker suffix. + */ + private function build_worker_id( string $suffix ): string { + return sprintf( 'clawpress-%s-%s', $suffix, substr( md5( uniqid( '', true ) ), 0, 12 ) ); + } + + /** + * Calculate exponential retry backoff. + * + * @param int $retry_count Retry count. + */ + private function calculate_retry_backoff( int $retry_count ): int { + $retry_count = max( 1, $retry_count ); + $delay = self::RETRY_BACKOFF_BASE * ( 2 ** ( $retry_count - 1 ) ); + return min( $delay, 15 * MINUTE_IN_SECONDS ); + } + + /** + * Check if run exceeded max wall time. + * + * @param array $run Run row. + * @param int $max_wall_time_seconds Max wall-time budget in seconds. + */ + private function has_run_exceeded_wall_time( array $run, int $max_wall_time_seconds ): bool { + $started_at = isset( $run['started_at_gmt'] ) ? (string) $run['started_at_gmt'] : ''; + if ( '' === $started_at || $max_wall_time_seconds <= 0 ) { + return false; + } + + $started_ts = strtotime( $started_at ); + if ( false === $started_ts ) { + return false; + } + + return ( time() - $started_ts ) >= $max_wall_time_seconds; + } +} diff --git a/includes/class-plugin.php b/includes/class-plugin.php index cba76e8..f2404ae 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -16,6 +16,7 @@ use ClawPress\Panel\Panel; use ClawPress\PostTypes\Post_Types; use ClawPress\RestAPI\Rest_API; +use ClawPress\Runner\Agent_Runner; use ClawPress\Stores\Action_Log_Store; use ClawPress\Stores\Agent_Event_Store; use ClawPress\Stores\Agent_Run_Store; @@ -44,6 +45,7 @@ private function __construct() { new Admin_Page(); new Panel(); new Heartbeat(); + new Agent_Runner(); // Initialize AI client. Goto Settings -> AI Credentials to set up. add_action( 'init', [ 'WordPress\AI_Client\AI_Client', 'init' ] ); diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php index 5e1c4f4..065248a 100644 --- a/includes/class-rest-api.php +++ b/includes/class-rest-api.php @@ -16,6 +16,7 @@ namespace ClawPress\RestAPI; use ClawPress\RestAPI\Controllers\Chat_Controller; +use ClawPress\RestAPI\Controllers\Agent_Run_Controller; use ClawPress\RestAPI\Controllers\Panel_State_Controller; use ClawPress\RestAPI\Controllers\Route_Controller; use ClawPress\RestAPI\Controllers\Settings_Controller; @@ -43,6 +44,7 @@ public function __construct() { new Status_Controller(), new Panel_State_Controller(), new Chat_Controller(), + new Agent_Run_Controller(), ]; add_action( 'rest_api_init', [ $this, 'register_routes' ] ); diff --git a/includes/class-security.php b/includes/class-security.php index f7cf65a..c3b472c 100644 --- a/includes/class-security.php +++ b/includes/class-security.php @@ -61,10 +61,20 @@ public function requires_confirmation_for_safety_class( string $safety_class ): /** * Validate requesting-user access to ClawPress routes. * + * @param int|null $requesting_user_id Optional requesting user ID. * @return true|\WP_Error */ - public function assert_requesting_user_allowed() { - if ( function_exists( 'clawpress_check_permissions' ) && ! clawpress_check_permissions() ) { + public function assert_requesting_user_allowed( ?int $requesting_user_id = null ) { + $resolved_user_id = null === $requesting_user_id ? 0 : (int) $requesting_user_id; + $has_access = true; + + if ( $resolved_user_id > 0 && function_exists( 'clawpress_check_permissions_for_user' ) ) { + $has_access = clawpress_check_permissions_for_user( $resolved_user_id ); + } elseif ( function_exists( 'clawpress_check_permissions' ) ) { + $has_access = clawpress_check_permissions(); + } + + if ( ! $has_access ) { return new \WP_Error( 'clawpress_requesting_user_forbidden', __( 'The requesting user is not allowed to use ClawPress.', 'clawpress' ) diff --git a/includes/functions.php b/includes/functions.php index 45bda9b..75e23bb 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -7,6 +7,8 @@ declare( strict_types=1 ); +defined( 'ABSPATH' ) || exit; + if ( ! function_exists( 'clawpress_sanitize_boolean' ) ) { /** * Sanitize a boolean value. @@ -259,23 +261,41 @@ function clawpress_sanitize_provider( $value ): string { } } -if ( ! function_exists( 'clawpress_check_permissions' ) ) { +if ( ! function_exists( 'clawpress_check_permissions_for_user' ) ) { /** - * Check permissions for ClawPress routes. + * Check permissions for a specific user. * * Filter hook: `clawpress_permissions_capability`. + * + * @param int $user_id User ID. */ - function clawpress_check_permissions(): bool { + function clawpress_check_permissions_for_user( int $user_id ): bool { $capability = apply_filters( 'clawpress_permissions_capability', 'manage_options' ); if ( empty( $capability ) ) { $capability = 'manage_options'; } + if ( $user_id > 0 && function_exists( 'user_can' ) ) { + return user_can( $user_id, $capability ); + } + return current_user_can( $capability ); } } +if ( ! function_exists( 'clawpress_check_permissions' ) ) { + /** + * Check permissions for ClawPress routes. + * + * Filter hook: `clawpress_permissions_capability`. + */ + function clawpress_check_permissions(): bool { + $user_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0; + return clawpress_check_permissions_for_user( $user_id ); + } +} + if ( ! function_exists( 'clawpress_sanitize_multiline_text' ) ) { /** * Sanitize multiline plain text for deterministic command responses. diff --git a/includes/helpers/class-abilities-helper.php b/includes/helpers/class-abilities-helper.php index 47452b2..a69dd95 100644 --- a/includes/helpers/class-abilities-helper.php +++ b/includes/helpers/class-abilities-helper.php @@ -266,7 +266,9 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e return $payload; } - $access_check = $this->security->assert_requesting_user_allowed(); + $access_check = $this->security->assert_requesting_user_allowed( + $requesting_user_id > 0 ? $requesting_user_id : null + ); if ( is_wp_error( $access_check ) ) { $payload = [ 'success' => false, @@ -294,7 +296,16 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e $runtime_policy, 'deny_tools' ); - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'warning', $args_hash, $payload, $event_context ); + $this->log_tool_call( + $normalized_tool_name, + $ability_name, + $requesting_user_id, + $execution_user_id, + $this->resolve_policy_violation_log_status( $payload ), + $args_hash, + $payload, + $event_context + ); return $payload; } @@ -308,7 +319,16 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e $runtime_policy, 'deny_destructive_tools' ); - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'warning', $args_hash, $payload, $event_context ); + $this->log_tool_call( + $normalized_tool_name, + $ability_name, + $requesting_user_id, + $execution_user_id, + $this->resolve_policy_violation_log_status( $payload ), + $args_hash, + $payload, + $event_context + ); return $payload; } @@ -322,7 +342,16 @@ public function execute_tool_call( string $tool_name, $raw_args = null, array $e $runtime_policy, 'deny_file_delete' ); - $this->log_tool_call( $normalized_tool_name, $ability_name, $requesting_user_id, $execution_user_id, 'warning', $args_hash, $payload, $event_context ); + $this->log_tool_call( + $normalized_tool_name, + $ability_name, + $requesting_user_id, + $execution_user_id, + $this->resolve_policy_violation_log_status( $payload ), + $args_hash, + $payload, + $event_context + ); return $payload; } @@ -571,24 +600,67 @@ private function build_policy_violation_payload( array $runtime_policy, string $decision ): array { + $on_violation = isset( $runtime_policy['on_policy_violation'] ) + ? strtolower( trim( (string) $runtime_policy['on_policy_violation'] ) ) + : 'deny'; + if ( ! in_array( $on_violation, [ 'deny', 'degrade', 'fail' ], true ) ) { + $on_violation = 'deny'; + } + + $policy = [ + 'trigger_type' => isset( $runtime_policy['trigger_type'] ) ? (string) $runtime_policy['trigger_type'] : 'chat', + 'policy_profile' => isset( $runtime_policy['policy_profile'] ) ? (string) $runtime_policy['policy_profile'] : 'default', + 'on_violation' => $on_violation, + 'decision' => $decision, + ]; + + if ( 'degrade' === $on_violation ) { + return [ + 'success' => true, + 'degraded' => true, + 'tool' => $tool_name, + 'ability' => $ability_name, + 'safety_class' => $safety_class, + 'result' => [ + 'message' => $message, + 'policy_decision' => $decision, + ], + 'policy' => $policy, + ]; + } + + $error_code = 'fail' === $on_violation ? $code . '_fail' : $code; + return [ 'success' => false, 'error' => [ - 'code' => $code, + 'code' => $error_code, 'message' => $message, ], 'tool' => $tool_name, 'ability' => $ability_name, 'safety_class' => $safety_class, - 'policy' => [ - 'trigger_type' => isset( $runtime_policy['trigger_type'] ) ? (string) $runtime_policy['trigger_type'] : 'chat', - 'policy_profile' => isset( $runtime_policy['policy_profile'] ) ? (string) $runtime_policy['policy_profile'] : 'default', - 'on_violation' => isset( $runtime_policy['on_policy_violation'] ) ? (string) $runtime_policy['on_policy_violation'] : 'deny', - 'decision' => $decision, - ], + 'policy' => $policy, ]; } + /** + * Determine action-log status for policy violations. + * + * @param array $payload Policy violation payload. + */ + private function resolve_policy_violation_log_status( array $payload ): string { + if ( ! empty( $payload['success'] ) ) { + return 'success'; + } + + $on_violation = isset( $payload['policy']['on_violation'] ) + ? strtolower( trim( (string) $payload['policy']['on_violation'] ) ) + : 'deny'; + + return 'fail' === $on_violation ? 'error' : 'warning'; + } + /** * Emit one tool-call event row. * diff --git a/includes/helpers/class-agent-loop-helper.php b/includes/helpers/class-agent-loop-helper.php new file mode 100644 index 0000000..9832908 --- /dev/null +++ b/includes/helpers/class-agent-loop-helper.php @@ -0,0 +1,1538 @@ +> + */ + private const MODEL_CONTEXT_WINDOWS = [ + 'openai' => [ + 'gpt-4.1-mini' => 1048576, + 'gpt-4.1' => 1048576, + 'gpt-4o-mini' => 128000, + 'gpt-4o' => 128000, + 'gpt-5' => 400000, + 'o3' => 200000, + 'o1' => 200000, + ], + 'anthropic' => [ + 'claude-' => 200000, + ], + 'google' => [ + 'gemini-2.5' => 1048576, + 'gemini-2.0' => 1048576, + 'gemini-1.5' => 1048576, + ], + ]; + + /** + * Singleton instance. + * + * @var ?self + */ + private static ?self $instance = null; + + /** + * Settings helper. + * + * @var Settings_Helper + */ + private Settings_Helper $settings_helper; + + /** + * Provider helper. + * + * @var Provider_Helper + */ + private Provider_Helper $provider_helper; + + /** + * Context helper. + * + * @var Context_Helper + */ + private Context_Helper $context_helper; + + /** + * Abilities helper. + * + * @var Abilities_Helper + */ + private Abilities_Helper $abilities_helper; + + /** + * Confirmation store. + * + * @var Command_Confirmation_Store + */ + private Command_Confirmation_Store $confirmation_store; + + /** + * Policy helper. + * + * @var Policy_Helper + */ + private Policy_Helper $policy_helper; + + /** + * Constructor. + */ + private function __construct() { + $this->settings_helper = Settings_Helper::get_instance(); + $this->provider_helper = Provider_Helper::get_instance(); + $this->context_helper = Context_Helper::get_instance(); + $this->abilities_helper = Abilities_Helper::get_instance(); + $this->confirmation_store = new Command_Confirmation_Store(); + $this->policy_helper = Policy_Helper::get_instance(); + } + + /** + * Get singleton instance. + */ + public static function get_instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Execute a full turn. + * + * @param array $turn_request Turn request payload. + * @return array + */ + public function run_turn( array $turn_request ): array { + return $this->run_internal( $turn_request, false ); + } + + /** + * Execute a bounded runtime slice. + * + * @param array $turn_request Turn request payload. + * @return array + */ + public function run_slice( array $turn_request ): array { + return $this->run_internal( $turn_request, true ); + } + + /** + * Execute turn/slice internals. + * + * @param array $turn_request Turn request payload. + * @param bool $is_slice Whether slice execution mode is enabled. + */ + private function run_internal( array $turn_request, bool $is_slice ): array { + $settings = $this->settings_helper->get_settings(); + $provider_model_resolver = isset( $turn_request['provider_model_resolver'] ) && is_callable( $turn_request['provider_model_resolver'] ) + ? $turn_request['provider_model_resolver'] + : [ $this->provider_helper, 'resolve_provider_and_model' ]; + $resolved = call_user_func( $provider_model_resolver, $settings ); + $provider = isset( $resolved['provider'] ) ? trim( (string) $resolved['provider'] ) : ''; + $model = isset( $resolved['model'] ) ? trim( (string) $resolved['model'] ) : ''; + $run_id = isset( $turn_request['run_id'] ) ? (int) $turn_request['run_id'] : 0; + $session_id = isset( $turn_request['session_id'] ) ? (int) $turn_request['session_id'] : 0; + $trigger = isset( $turn_request['trigger'] ) ? (string) $turn_request['trigger'] : 'chat'; + $transport_mode = isset( $turn_request['transport_mode'] ) ? (string) $turn_request['transport_mode'] : 'polling'; + + if ( '' === $provider ) { + return [ + 'status' => 'success', + 'next_action' => 'stop', + 'mode' => 'offline', + 'provider' => null, + 'model' => null, + 'assistant_text' => '', + 'tool_calls' => [], + 'card' => null, + 'context' => null, + ]; + } + + $transport = $this->create_transport( $transport_mode, $run_id, $session_id ); + $online_reply_generator = isset( $turn_request['online_reply_generator'] ) && is_callable( $turn_request['online_reply_generator'] ) + ? $turn_request['online_reply_generator'] + : [ $this, 'generate_online_reply' ]; + + try { + $requesting_user_id = isset( $turn_request['requesting_user_id'] ) + ? (int) $turn_request['requesting_user_id'] + : ( function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0 ); + + $context = $this->context_helper->build_model_context( + isset( $turn_request['message'] ) ? (string) $turn_request['message'] : '', + $requesting_user_id > 0 ? $requesting_user_id : null + ); + $context['request_timeout'] = $this->settings_helper->get_request_timeout( $settings ); + $context['generation_settings'] = $this->settings_helper->get_generation_settings( $settings ); + + $transport->emit( + [ + 'type' => 'agent.run.started', + 'payload' => [ + 'run_id' => $run_id > 0 ? $run_id : null, + 'session_id' => $session_id > 0 ? $session_id : null, + 'trigger' => $trigger, + 'attempt' => isset( $turn_request['attempt'] ) ? (int) $turn_request['attempt'] : 1, + 'transport' => $transport_mode, + 'slice_mode' => $is_slice, + ], + ] + ); + + $online_reply_payload = $this->normalize_online_reply_payload( + $this->invoke_online_reply_generator( + $online_reply_generator, + $context, + $provider, + $model, + $turn_request, + $transport, + $is_slice + ) + ); + + $result = [ + 'status' => isset( $online_reply_payload['status'] ) ? (string) $online_reply_payload['status'] : 'success', + 'next_action' => isset( $online_reply_payload['next_action'] ) ? (string) $online_reply_payload['next_action'] : 'stop', + 'mode' => 'online', + 'provider' => $provider, + 'model' => '' !== $model ? $model : null, + 'assistant_text' => (string) $online_reply_payload['reply'], + 'card' => $online_reply_payload['card'], + 'context' => $online_reply_payload['context'], + 'tool_calls' => $online_reply_payload['tool_calls'], + 'resume_cursor' => isset( $online_reply_payload['resume_cursor'] ) ? $online_reply_payload['resume_cursor'] : null, + ]; + $result = $this->ensure_terminal_assistant_text( $result ); + + if ( isset( $online_reply_payload['events_cursor'] ) ) { + $result['events_cursor'] = (int) $online_reply_payload['events_cursor']; + } + + if ( isset( $online_reply_payload['error'] ) && is_array( $online_reply_payload['error'] ) ) { + $result['error'] = $online_reply_payload['error']; + $result['mode'] = 'error'; + } + + $transport->emit( + [ + 'type' => 'agent.run.finished', + 'payload' => [ + 'status' => $result['status'], + 'next_action' => $result['next_action'], + ], + ] + ); + + if ( $transport instanceof Polling_Transport ) { + $result['events_cursor'] = $transport->get_last_event_id(); + } + + return $result; + } catch ( Throwable $throwable ) { + $error_message = trim( sanitize_text_field( $throwable->getMessage() ) ); + if ( '' === $error_message ) { + $error_message = __( 'Unknown provider error.', 'clawpress' ); + } + + $error_type = $this->classify_provider_error_type( $throwable, $error_message ); + $transport->emit( + [ + 'type' => 'agent.run.error', + 'payload' => [ + 'error_type' => $error_type, + 'message' => $error_message, + ], + ] + ); + + $result = [ + 'status' => 'timeout' === $error_type ? 'timeout' : 'error', + 'next_action' => 'stop', + 'mode' => 'error', + 'provider' => $provider, + 'model' => '' !== $model ? $model : null, + 'assistant_text' => '', + 'card' => null, + 'context' => null, + 'tool_calls' => [], + 'error' => [ + 'type' => $error_type, + 'message' => $error_message, + 'code' => is_int( $throwable->getCode() ) || is_string( $throwable->getCode() ) ? $throwable->getCode() : 0, + 'retryable' => 'timeout' === $error_type, + ], + ]; + + if ( $transport instanceof Polling_Transport ) { + $result['events_cursor'] = $transport->get_last_event_id(); + } + + return $result; + } finally { + $transport->close(); + } + } + + /** + * Ensure terminal online responses always include user-facing assistant text. + * + * @param array $result Runtime result payload. + * @return array + */ + private function ensure_terminal_assistant_text( array $result ): array { + $mode = isset( $result['mode'] ) ? strtolower( trim( (string) $result['mode'] ) ) : ''; + $status = isset( $result['status'] ) ? strtolower( trim( (string) $result['status'] ) ) : ''; + + if ( 'online' !== $mode || ! in_array( $status, [ 'success', 'done', 'requires_confirmation' ], true ) ) { + return $result; + } + + $assistant_text = isset( $result['assistant_text'] ) ? trim( (string) $result['assistant_text'] ) : ''; + if ( '' !== $assistant_text ) { + $result['assistant_text'] = $assistant_text; + return $result; + } + + $card_message = isset( $result['card']['data']['message'] ) ? trim( (string) $result['card']['data']['message'] ) : ''; + if ( '' !== $card_message ) { + $result['assistant_text'] = $card_message; + return $result; + } + + if ( 'requires_confirmation' === $status ) { + $result['assistant_text'] = __( 'Action requires confirmation before continuing.', 'clawpress' ); + return $result; + } + + $result['assistant_text'] = __( + 'I finished the background steps, but I did not receive a final text response. Please tell me to continue and I will pick up from here.', + 'clawpress' + ); + return $result; + } + + /** + * Generate online reply using model/tool execution loop. + * + * @param array $context Model context payload. + * @param string $provider Provider identifier. + * @param string $model Model identifier. + * @param array $turn_request Turn request metadata. + * @param Agent_Transport|null $transport Transport implementation. + * @param bool $is_slice Whether slice execution mode is enabled. + * @return array + */ + private function generate_online_reply( array $context, string $provider, string $model, array $turn_request = [], ?Agent_Transport $transport = null, bool $is_slice = false ): array { + $current_message = isset( $context['message'] ) ? trim( (string) $context['message'] ) : ''; + $system_prompt = isset( $context['system_prompt'] ) ? trim( (string) $context['system_prompt'] ) : ''; + $request_timeout = isset( $context['request_timeout'] ) ? (int) $context['request_timeout'] : 45; + $generation_settings = isset( $context['generation_settings'] ) && is_array( $context['generation_settings'] ) + ? $this->normalize_generation_settings( $context['generation_settings'] ) + : $this->settings_helper->get_generation_settings(); + $history_messages = $this->normalize_history_messages( $context ); + $tool_declarations = $this->normalize_tool_declarations( $context ); + $requesting_user_id = isset( $turn_request['requesting_user_id'] ) ? (int) $turn_request['requesting_user_id'] : ( isset( $context['requesting_user_id'] ) ? (int) $context['requesting_user_id'] : 0 ); + $execution_user_id = isset( $turn_request['execution_user_id'] ) ? (int) $turn_request['execution_user_id'] : ( isset( $context['execution_user_id'] ) ? (int) $context['execution_user_id'] : 0 ); + $trigger_type = isset( $turn_request['trigger'] ) ? (string) $turn_request['trigger'] : ( isset( $context['trigger_type'] ) ? (string) $context['trigger_type'] : 'chat' ); + $runtime_policy = $this->policy_helper->resolve_runtime_policy( + $trigger_type, + isset( $turn_request['session_metadata'] ) && is_array( $turn_request['session_metadata'] ) + ? $turn_request['session_metadata'] + : [], + isset( $turn_request['policy_overrides'] ) && is_array( $turn_request['policy_overrides'] ) + ? $turn_request['policy_overrides'] + : [] + ); + $run_id = isset( $turn_request['run_id'] ) ? (int) $turn_request['run_id'] : 0; + $session_id = isset( $turn_request['session_id'] ) ? (int) $turn_request['session_id'] : 0; + $slice_budget_ms = $is_slice ? max( 1, (int) ( $turn_request['slice_budget_ms'] ?? 1500 ) ) : 0; + $max_steps_per_slice = $is_slice ? max( 1, (int) ( $turn_request['max_steps_per_slice'] ?? 1 ) ) : PHP_INT_MAX; + $resume_state = $this->normalize_resume_cursor( $turn_request['resume_cursor'] ?? null ); + $transport = $transport ?? new Null_Transport(); + + $this->confirmation_store->clear_tool_batch( $requesting_user_id > 0 ? $requesting_user_id : null ); + + $conversation = []; + if ( isset( $resume_state['conversation'] ) && is_array( $resume_state['conversation'] ) ) { + $conversation = $this->restore_conversation( $resume_state['conversation'] ); + } + + if ( [] === $conversation ) { + $conversation = $history_messages; + if ( '' !== $current_message ) { + $conversation[] = ( new MessageBuilder( $current_message ) ) + ->usingUserRole() + ->get(); + } + } + + $latest_assistant_text = isset( $resume_state['assistant_text'] ) ? (string) $resume_state['assistant_text'] : ''; + $latest_context_usage = isset( $resume_state['context'] ) && is_array( $resume_state['context'] ) + ? $this->normalize_context_usage_payload( $resume_state['context'] ) + : null; + $tool_call_trace = isset( $resume_state['tool_calls'] ) && is_array( $resume_state['tool_calls'] ) + ? $this->normalize_tool_call_trace_payload( $resume_state['tool_calls'] ) + : []; + $round_start = isset( $resume_state['round'] ) ? max( 0, (int) $resume_state['round'] ) : 0; + $steps_completed = $is_slice + ? 0 + : ( isset( $resume_state['steps_completed'] ) ? max( 0, (int) $resume_state['steps_completed'] ) : 0 ); + + if ( $is_slice && $round_start > 0 ) { + $transport->emit( + [ + 'type' => 'agent.slice.resumed', + 'payload' => [ + 'round' => $round_start, + ], + ] + ); + } + + $started_at_ms = $this->now_ms(); + + for ( $round = $round_start; $round < (int) $runtime_policy['max_tool_rounds']; ++$round ) { + if ( $is_slice && $steps_completed > 0 && $this->should_pause_slice( $steps_completed, $max_steps_per_slice, $started_at_ms, $slice_budget_ms ) ) { + $resume_cursor = $this->build_resume_cursor( + $conversation, + $round, + $steps_completed, + $latest_assistant_text, + $tool_call_trace, + $latest_context_usage + ); + + $transport->emit( + [ + 'type' => 'agent.slice.paused', + 'payload' => [ + 'round' => $round, + 'steps_completed' => $steps_completed, + ], + ] + ); + + return [ + 'status' => 'in_progress', + 'next_action' => 'continue_later', + 'reply' => $latest_assistant_text, + 'card' => null, + 'context' => $latest_context_usage, + 'tool_calls' => $tool_call_trace, + 'resume_cursor' => $resume_cursor, + ]; + } + + $builder = AiClient::prompt( $conversation )->usingProvider( $provider ); + if ( '' !== $system_prompt ) { + $builder = $builder->usingSystemInstruction( $system_prompt ); + } + + if ( '' !== $model ) { + $builder = $builder->usingModelPreference( [ $provider, $model ] ); + } + + $builder = $builder->usingRequestOptions( $this->build_request_options( $request_timeout ) ); + $builder = $this->apply_generation_settings_to_prompt_builder( $builder, $generation_settings, $provider, $model ); + if ( [] !== $tool_declarations ) { + $builder = $builder->usingFunctionDeclarations( ...$tool_declarations ); + } + + $result = $builder->generateResult(); + $assistant_message = $result->toMessage(); + $conversation[] = $assistant_message; + $latest_assistant_text = $this->extract_text_from_message( $assistant_message ); + $latest_context_usage = $this->extract_context_usage_from_result( $result, $provider, $model ); + ++$steps_completed; + + $function_calls = $this->extract_function_calls( $assistant_message ); + $transport->emit( + [ + 'type' => 'agent.llm.response', + 'payload' => [ + 'round' => $round + 1, + 'tool_call_count' => count( $function_calls ), + 'assistant_excerpt' => '' !== $latest_assistant_text ? mb_substr( $latest_assistant_text, 0, 300 ) : '', + ], + ] + ); + + if ( [] === $function_calls ) { + return [ + 'status' => 'success', + 'next_action' => 'stop', + 'reply' => $latest_assistant_text, + 'card' => null, + 'context' => $latest_context_usage, + 'tool_calls' => $tool_call_trace, + ]; + } + + $function_responses = []; + $confirmation_batch = []; + + $max_tool_calls_per_round = max( 1, (int) $runtime_policy['max_tool_calls_per_round'] ); + foreach ( $function_calls as $index => $function_call ) { + $tool_name = trim( (string) $function_call->getName() ); + if ( '' === $tool_name ) { + $tool_name = 'unknown_tool'; + } + + $function_response_id = trim( (string) $function_call->getId() ); + if ( '' === $function_response_id ) { + $function_response_id = sprintf( 'tool-call-%d-%d', $round + 1, $index + 1 ); + } + + if ( $index >= $max_tool_calls_per_round ) { + $deferred_count = count( $function_calls ) - $index; + $this->append_deferred_tool_call_responses( + $function_calls, + $index, + $round + 1, + $max_tool_calls_per_round, + $function_responses + ); + + $transport->emit( + [ + 'type' => 'agent.tool_calls.deferred', + 'payload' => [ + 'round' => $round + 1, + 'deferred_count' => max( 1, $deferred_count ), + 'executed_count' => $max_tool_calls_per_round, + ], + ] + ); + + break; + } + + $tool_result = $this->abilities_helper->execute_tool_call( + $tool_name, + $function_call->getArgs(), + [ + 'run_id' => $run_id, + 'session_id' => $session_id, + 'requesting_user_id' => $requesting_user_id, + 'execution_user_id' => $execution_user_id, + 'confirmation_scope' => 'batch', + 'trigger_type' => $trigger_type, + 'runtime_policy' => $runtime_policy, + ] + ); + $tool_call_trace[] = $this->build_tool_call_trace_entry( + $tool_name, + $function_call->getArgs(), + $tool_result, + $round + 1, + $index + 1 + ); + $latest_tool_trace = $tool_call_trace[ count( $tool_call_trace ) - 1 ]; + $tool_call_status = isset( $latest_tool_trace['status'] ) ? (string) $latest_tool_trace['status'] : 'success'; + $tool_call_payload = [ + 'round' => $round + 1, + 'sequence' => $index + 1, + 'tool_name' => strtolower( trim( $tool_name ) ), + 'status' => $tool_call_status, + ]; + if ( isset( $latest_tool_trace['message'] ) && is_string( $latest_tool_trace['message'] ) && '' !== $latest_tool_trace['message'] ) { + $tool_call_payload['message'] = $latest_tool_trace['message']; + } + + $transport->emit( + [ + 'type' => 'agent.tool_call', + 'payload' => $tool_call_payload, + ] + ); + + $pending_confirmation = $this->normalize_pending_confirmation_tool_call( + $tool_result, + $tool_name, + $function_call->getArgs() + ); + if ( is_array( $pending_confirmation ) ) { + $confirmation_batch[] = $pending_confirmation; + } + + $function_responses[] = new FunctionResponse( + $function_response_id, + $tool_name, + $tool_result + ); + } + + if ( [] === $function_responses ) { + return [ + 'status' => 'success', + 'next_action' => 'stop', + 'reply' => $latest_assistant_text, + 'card' => null, + 'context' => $latest_context_usage, + 'tool_calls' => $tool_call_trace, + ]; + } + + if ( [] !== $confirmation_batch ) { + $issued_batch = $this->confirmation_store->issue_tool_batch( + $confirmation_batch, + $requesting_user_id > 0 ? $requesting_user_id : null + ); + + $transport->emit( + [ + 'type' => 'agent.confirmation.required', + 'payload' => [ + 'round' => $round + 1, + 'tool_count' => count( $confirmation_batch ), + ], + ] + ); + + return [ + 'status' => 'requires_confirmation', + 'next_action' => 'stop', + 'reply' => $latest_assistant_text, + 'card' => $this->build_tool_confirmation_card( $issued_batch ), + 'context' => $latest_context_usage, + 'tool_calls' => $tool_call_trace, + ]; + } + + foreach ( $function_responses as $function_response ) { + $tool_response_message_builder = new MessageBuilder(); + $tool_response_message_builder->usingUserRole(); + $tool_response_message_builder->withFunctionResponse( $function_response ); + $conversation[] = $tool_response_message_builder->get(); + } + + if ( $is_slice && $this->should_pause_slice( $steps_completed, $max_steps_per_slice, $started_at_ms, $slice_budget_ms ) ) { + $resume_cursor = $this->build_resume_cursor( + $conversation, + $round + 1, + $steps_completed, + $latest_assistant_text, + $tool_call_trace, + $latest_context_usage + ); + + $transport->emit( + [ + 'type' => 'agent.slice.paused', + 'payload' => [ + 'round' => $round + 1, + 'steps_completed' => $steps_completed, + ], + ] + ); + + return [ + 'status' => 'in_progress', + 'next_action' => 'continue_later', + 'reply' => $latest_assistant_text, + 'card' => null, + 'context' => $latest_context_usage, + 'tool_calls' => $tool_call_trace, + 'resume_cursor' => $resume_cursor, + ]; + } + } + + return [ + 'status' => 'success', + 'next_action' => 'stop', + 'reply' => $latest_assistant_text, + 'card' => null, + 'context' => $latest_context_usage, + 'tool_calls' => $tool_call_trace, + ]; + } + + /** + * Normalize online generator output. + * + * @param mixed $raw_output Raw callback output. + * @return array + */ + private function normalize_online_reply_payload( $raw_output ): array { + if ( is_array( $raw_output ) ) { + $reply = isset( $raw_output['reply'] ) ? (string) $raw_output['reply'] : ''; + $card = isset( $raw_output['card'] ) && is_array( $raw_output['card'] ) + ? $this->normalize_card_payload( $raw_output['card'] ) + : null; + $context = isset( $raw_output['context'] ) && is_array( $raw_output['context'] ) + ? $this->normalize_context_usage_payload( $raw_output['context'] ) + : null; + $tool_calls = isset( $raw_output['tool_calls'] ) && is_array( $raw_output['tool_calls'] ) + ? $this->normalize_tool_call_trace_payload( $raw_output['tool_calls'] ) + : []; + + $payload = [ + 'reply' => $reply, + 'card' => $card, + 'context' => $context, + 'tool_calls' => $tool_calls, + 'status' => isset( $raw_output['status'] ) ? (string) $raw_output['status'] : 'success', + 'next_action' => isset( $raw_output['next_action'] ) ? (string) $raw_output['next_action'] : 'stop', + ]; + + if ( isset( $raw_output['resume_cursor'] ) ) { + $payload['resume_cursor'] = $raw_output['resume_cursor']; + } + + if ( isset( $raw_output['error'] ) && is_array( $raw_output['error'] ) ) { + $payload['error'] = $raw_output['error']; + } + + if ( isset( $raw_output['events_cursor'] ) ) { + $payload['events_cursor'] = (int) $raw_output['events_cursor']; + } + + return $payload; + } + + return [ + 'reply' => (string) $raw_output, + 'card' => null, + 'context' => null, + 'tool_calls' => [], + 'status' => 'success', + 'next_action' => 'stop', + ]; + } + + /** + * Create transport from transport mode. + * + * @param string $transport_mode Transport mode. + * @param int $run_id Run identifier. + * @param int $session_id Session identifier. + */ + private function create_transport( string $transport_mode, int $run_id, int $session_id ): Agent_Transport { + $transport_mode = strtolower( trim( $transport_mode ) ); + if ( in_array( $transport_mode, [ 'polling', 'streaming' ], true ) && ( $run_id > 0 || $session_id > 0 ) ) { + return new Polling_Transport( $run_id, $session_id ); + } + + return new Null_Transport(); + } + + /** + * Determine whether current slice should pause. + * + * @param int $steps_completed Completed steps in current run. + * @param int $max_steps_per_slice Max steps allowed per slice. + * @param int $started_at_ms Slice start timestamp in milliseconds. + * @param int $slice_budget_ms Wall-time budget for this slice. + */ + private function should_pause_slice( int $steps_completed, int $max_steps_per_slice, int $started_at_ms, int $slice_budget_ms ): bool { + if ( $steps_completed >= $max_steps_per_slice ) { + return true; + } + + if ( $slice_budget_ms <= 0 ) { + return false; + } + + return ( $this->now_ms() - $started_at_ms ) >= $slice_budget_ms; + } + + /** + * Create resume cursor payload. + * + * @param array $conversation Conversation state. + * @param int $round Next round. + * @param int $steps_completed Completed steps. + * @param string $assistant_text Latest assistant text. + * @param array> $tool_calls Tool trace. + * @param array|null $context_usage Context usage payload. + * @return array + */ + private function build_resume_cursor( + array $conversation, + int $round, + int $steps_completed, + string $assistant_text, + array $tool_calls, + ?array $context_usage + ): array { + return [ + 'version' => 1, + 'round' => max( 0, $round ), + 'steps_completed' => max( 0, $steps_completed ), + 'assistant_text' => $assistant_text, + 'tool_calls' => $tool_calls, + 'context' => $context_usage, + 'conversation' => array_values( + array_map( + static fn ( Message $message ): array => $message->toArray(), + $conversation + ) + ), + ]; + } + + /** + * Normalize resume cursor payload. + * + * @param mixed $resume_cursor Resume cursor payload. + * @return array + */ + private function normalize_resume_cursor( $resume_cursor ): array { + if ( is_string( $resume_cursor ) && '' !== trim( $resume_cursor ) ) { + $decoded = json_decode( $resume_cursor, true ); + if ( is_array( $decoded ) ) { + $resume_cursor = $decoded; + } + } + + if ( ! is_array( $resume_cursor ) ) { + return []; + } + + if ( ! isset( $resume_cursor['conversation'] ) || ! is_array( $resume_cursor['conversation'] ) ) { + $resume_cursor['conversation'] = []; + } + + return $resume_cursor; + } + + /** + * Restore conversation from serialized resume payload. + * + * @param array $serialized_conversation Serialized conversation array. + * @return array + */ + private function restore_conversation( array $serialized_conversation ): array { + $conversation = []; + + foreach ( $serialized_conversation as $message ) { + if ( ! is_array( $message ) ) { + continue; + } + + try { + $converted = Message::fromArray( $message ); + if ( $converted instanceof Message ) { + $conversation[] = $converted; + } + } catch ( Throwable $throwable ) { + unset( $throwable ); + continue; + } + } + + return $conversation; + } + + /** + * Build a compact trace row for a single tool call. + * + * @param string $tool_name Tool name. + * @param mixed $raw_args Tool arguments. + * @param array $tool_result Tool result payload. + * @param int $round Tool round number. + * @param int $sequence Tool sequence in round. + * @return array + */ + private function build_tool_call_trace_entry( + string $tool_name, + $raw_args, + array $tool_result, + int $round, + int $sequence + ): array { + $normalized_tool_name = strtolower( trim( $tool_name ) ); + $ability_name = isset( $tool_result['ability'] ) ? trim( (string) $tool_result['ability'] ) : ''; + $requires_confirmation = isset( $tool_result['requires_confirmation'] ) && true === $tool_result['requires_confirmation']; + $success = isset( $tool_result['success'] ) && true === $tool_result['success']; + $status = $success ? 'success' : ( $requires_confirmation ? 'requires_confirmation' : 'error' ); + $message = ''; + + if ( isset( $tool_result['error']['message'] ) ) { + $message = trim( sanitize_text_field( (string) $tool_result['error']['message'] ) ); + } elseif ( isset( $tool_result['result']['message'] ) ) { + $message = trim( sanitize_text_field( (string) $tool_result['result']['message'] ) ); + } + + if ( '' !== $message && strlen( $message ) > 200 ) { + $message = substr( $message, 0, 197 ) . '...'; + } + + return [ + 'name' => $normalized_tool_name, + 'ability' => '' !== $ability_name ? $ability_name : null, + 'args' => $this->normalize_tool_args_for_prompt( $raw_args ), + 'status' => $status, + 'requires_confirmation' => $requires_confirmation, + 'message' => '' !== $message ? $message : null, + 'round' => max( 1, $round ), + 'sequence' => max( 1, $sequence ), + ]; + } + + /** + * Build a synthetic tool result when the per-round execution cap is exceeded. + * + * @param int $max_tool_calls_per_round Per-round execution cap. + * @return array + */ + private function build_tool_call_limit_tool_result( int $max_tool_calls_per_round ): array { + return [ + 'success' => false, + 'requires_confirmation' => false, + 'ability' => '', + 'result' => [], + 'error' => [ + 'code' => 'tool_call_limit_exceeded', + /* translators: %d: maximum number of tool calls executed per round. */ + 'message' => sprintf( __( 'Tool call deferred because the per-round limit of %d was reached. Retry this call in the next round.', 'clawpress' ), max( 1, $max_tool_calls_per_round ) ), + ], + ]; + } + + /** + * Append synthetic responses for deferred tool calls once round cap is reached. + * + * @param array $function_calls Full round function calls. + * @param int $start_index First deferred call index. + * @param int $round Tool round number. + * @param int $max_tool_calls_per_round Per-round execution cap. + * @param array $function_responses Accumulator. + */ + private function append_deferred_tool_call_responses( + array $function_calls, + int $start_index, + int $round, + int $max_tool_calls_per_round, + array &$function_responses + ): void { + $total_calls = count( $function_calls ); + for ( $index = $start_index; $index < $total_calls; ++$index ) { + $function_call = $function_calls[ $index ]; + if ( ! $function_call instanceof FunctionCall ) { + continue; + } + + $tool_name = trim( (string) $function_call->getName() ); + if ( '' === $tool_name ) { + $tool_name = 'unknown_tool'; + } + + $function_response_id = trim( (string) $function_call->getId() ); + if ( '' === $function_response_id ) { + $function_response_id = sprintf( 'tool-call-%d-%d', $round, $index + 1 ); + } + + $function_responses[] = new FunctionResponse( + $function_response_id, + $tool_name, + $this->build_tool_call_limit_tool_result( $max_tool_calls_per_round ) + ); + } + } + + /** + * Normalize tool call trace payload. + * + * @param array $tool_calls Raw trace payload. + * @return array> + */ + private function normalize_tool_call_trace_payload( array $tool_calls ): array { + $normalized = []; + + foreach ( $tool_calls as $index => $tool_call ) { + if ( ! is_array( $tool_call ) ) { + continue; + } + + $name = isset( $tool_call['name'] ) ? strtolower( trim( sanitize_text_field( (string) $tool_call['name'] ) ) ) : ''; + if ( '' === $name ) { + continue; + } + + $ability = isset( $tool_call['ability'] ) ? trim( sanitize_text_field( (string) $tool_call['ability'] ) ) : ''; + $args = isset( $tool_call['args'] ) ? $this->normalize_tool_args_for_prompt( $tool_call['args'] ) : []; + $status = isset( $tool_call['status'] ) ? strtolower( trim( (string) $tool_call['status'] ) ) : 'success'; + $status = in_array( $status, [ 'success', 'error', 'requires_confirmation' ], true ) ? $status : 'success'; + $message = isset( $tool_call['message'] ) ? trim( sanitize_text_field( (string) $tool_call['message'] ) ) : ''; + $round = isset( $tool_call['round'] ) ? (int) $tool_call['round'] : 1; + $sequence = isset( $tool_call['sequence'] ) ? (int) $tool_call['sequence'] : ( $index + 1 ); + + $normalized[] = [ + 'name' => $name, + 'ability' => '' !== $ability ? $ability : null, + 'args' => $args, + 'status' => $status, + 'requires_confirmation' => isset( $tool_call['requires_confirmation'] ) ? (bool) $tool_call['requires_confirmation'] : ( 'requires_confirmation' === $status ), + 'message' => '' !== $message ? $message : null, + 'round' => max( 1, $round ), + 'sequence' => max( 1, $sequence ), + ]; + } + + return $normalized; + } + + /** + * Build normalized context usage metadata from a provider result. + * + * @param GenerativeAiResult $result Result payload. + * @param string $provider Provider identifier. + * @param string $model Model identifier. + * @return array|null + */ + private function extract_context_usage_from_result( GenerativeAiResult $result, string $provider, string $model ): ?array { + $token_usage = $result->getTokenUsage(); + $prompt_tokens = max( 0, (int) $token_usage->getPromptTokens() ); + $completion_tokens = max( 0, (int) $token_usage->getCompletionTokens() ); + $total_tokens = max( 0, (int) $token_usage->getTotalTokens() ); + $context_used = $prompt_tokens > 0 ? $prompt_tokens : $total_tokens; + $context_window = $this->resolve_context_window_tokens( $provider, $model ); + $percent_used = null; + $percent_left = null; + $window_is_estimate = null; + + if ( $context_window > 0 ) { + $percent_used = (int) round( min( 100, ( $context_used / $context_window ) * 100 ) ); + $percent_left = max( 0, 100 - $percent_used ); + $window_is_estimate = true; + } + + if ( 0 === $prompt_tokens && 0 === $completion_tokens && 0 === $total_tokens && null === $percent_used ) { + return null; + } + + return [ + 'prompt_tokens' => $prompt_tokens, + 'completion_tokens' => $completion_tokens, + 'total_tokens' => $total_tokens, + 'used_tokens' => $context_used, + 'context_window_tokens' => $context_window > 0 ? $context_window : null, + 'percent_used' => $percent_used, + 'percent_left' => $percent_left, + 'window_is_estimated' => $window_is_estimate, + ]; + } + + /** + * Normalize context usage payload. + * + * @param array $context Raw context payload. + * @return array|null + */ + private function normalize_context_usage_payload( array $context ): ?array { + $prompt_tokens = isset( $context['prompt_tokens'] ) ? (int) $context['prompt_tokens'] : 0; + $completion_tokens = isset( $context['completion_tokens'] ) ? (int) $context['completion_tokens'] : 0; + $total_tokens = isset( $context['total_tokens'] ) ? (int) $context['total_tokens'] : 0; + $used_tokens = isset( $context['used_tokens'] ) ? (int) $context['used_tokens'] : max( 0, $prompt_tokens ); + $context_window = isset( $context['context_window_tokens'] ) ? (int) $context['context_window_tokens'] : 0; + $percent_used = isset( $context['percent_used'] ) ? (int) $context['percent_used'] : null; + $percent_left = isset( $context['percent_left'] ) ? (int) $context['percent_left'] : null; + $window_is_estimate = isset( $context['window_is_estimated'] ) ? (bool) $context['window_is_estimated'] : null; + + $prompt_tokens = max( 0, $prompt_tokens ); + $completion_tokens = max( 0, $completion_tokens ); + $total_tokens = max( 0, $total_tokens ); + $used_tokens = max( 0, $used_tokens ); + $context_window = max( 0, $context_window ); + + if ( $context_window > 0 ) { + if ( null === $percent_used ) { + $percent_used = (int) round( min( 100, ( $used_tokens / $context_window ) * 100 ) ); + } + + $percent_used = max( 0, min( 100, $percent_used ) ); + $percent_left = null !== $percent_left + ? max( 0, min( 100, $percent_left ) ) + : max( 0, 100 - $percent_used ); + } else { + $percent_used = null; + $percent_left = null; + } + + if ( 0 === $prompt_tokens && 0 === $completion_tokens && 0 === $total_tokens && 0 === $used_tokens && 0 === $context_window ) { + return null; + } + + return [ + 'prompt_tokens' => $prompt_tokens, + 'completion_tokens' => $completion_tokens, + 'total_tokens' => $total_tokens, + 'used_tokens' => $used_tokens, + 'context_window_tokens' => $context_window > 0 ? $context_window : null, + 'percent_used' => $percent_used, + 'percent_left' => $percent_left, + 'window_is_estimated' => $window_is_estimate, + ]; + } + + /** + * Resolve best-known context window for a provider/model pair. + * + * @param string $provider Provider identifier. + * @param string $model Model identifier. + */ + private function resolve_context_window_tokens( string $provider, string $model ): int { + $provider = clawpress_sanitize_provider( $provider ); + $model = strtolower( trim( $model ) ); + if ( '' === $provider || '' === $model || ! isset( self::MODEL_CONTEXT_WINDOWS[ $provider ] ) ) { + return 0; + } + + foreach ( self::MODEL_CONTEXT_WINDOWS[ $provider ] as $prefix => $window ) { + if ( 0 !== strpos( $model, strtolower( (string) $prefix ) ) ) { + continue; + } + + return max( 0, (int) $window ); + } + + return 0; + } + + /** + * Normalize card payload. + * + * @param array $card Raw card payload. + * @return array|null + */ + private function normalize_card_payload( array $card ): ?array { + $type = isset( $card['type'] ) ? strtolower( sanitize_text_field( (string) $card['type'] ) ) : ''; + $type = (string) preg_replace( '/[^a-z0-9_\-]/', '', $type ); + if ( '' === $type ) { + return null; + } + + return [ + 'type' => $type, + 'data' => isset( $card['data'] ) && is_array( $card['data'] ) + ? $card['data'] + : [], + ]; + } + + /** + * Build one pending confirmation row for a destructive tool call. + * + * @param array $tool_result Tool execution result. + * @param string $tool_name Tool name. + * @param mixed $raw_args Raw tool-call args. + * @return array|null + */ + private function normalize_pending_confirmation_tool_call( array $tool_result, string $tool_name, $raw_args ): ?array { + if ( empty( $tool_result['requires_confirmation'] ) ) { + return null; + } + + $normalized_tool_name = strtolower( trim( $tool_name ) ); + if ( '' === $normalized_tool_name ) { + return null; + } + + return [ + 'tool_name' => $normalized_tool_name, + 'ability_name' => isset( $tool_result['ability'] ) ? (string) $tool_result['ability'] : '', + 'args' => $this->normalize_tool_args_for_prompt( $raw_args ), + ]; + } + + /** + * Build a user-confirmation card payload. + * + * @param array|null $confirmation_batch Pending confirmation batch. + * @return array|null + */ + private function build_tool_confirmation_card( ?array $confirmation_batch ): ?array { + if ( ! is_array( $confirmation_batch ) ) { + return null; + } + + $batch_id = isset( $confirmation_batch['batch_id'] ) ? strtolower( trim( (string) $confirmation_batch['batch_id'] ) ) : ''; + $expires_at = isset( $confirmation_batch['expires_at'] ) ? (int) $confirmation_batch['expires_at'] : 0; + $calls = isset( $confirmation_batch['calls'] ) && is_array( $confirmation_batch['calls'] ) + ? array_values( $confirmation_batch['calls'] ) + : []; + + if ( '' === $batch_id || [] === $calls ) { + return null; + } + + $tool_names = array_values( + array_unique( + array_filter( + array_map( + static fn( $call ): string => isset( $call['tool_name'] ) + ? strtolower( trim( (string) $call['tool_name'] ) ) + : '', + $calls + ) + ) + ) + ); + $total_calls = count( $calls ); + $expires_at = $expires_at > 0 ? $expires_at : time(); + $expires_label = function_exists( 'wp_date' ) + ? wp_date( 'Y-m-d H:i:s', $expires_at ) + : gmdate( 'Y-m-d H:i:s', $expires_at ); + + if ( 1 === $total_calls ) { + $message = sprintf( + /* translators: 1: tool name, 2: batch ID, 3: expiry time */ + __( 'Confirm batch `%2$s` to run `%1$s`. This batch expires at %3$s.', 'clawpress' ), + $tool_names[0] ?? 'tool', + $batch_id, + $expires_label + ); + } else { + $message = sprintf( + /* translators: 1: total destructive calls, 2: batch ID, 3: comma-separated tool names, 4: expiry time */ + __( 'This reply queued %1$d destructive tool calls in batch `%2$s` (%3$s). Use Confirm All to execute the entire batch. This batch expires at %4$s.', 'clawpress' ), + $total_calls, + $batch_id, + [] !== $tool_names ? implode( ', ', $tool_names ) : __( 'tools', 'clawpress' ), + $expires_label + ); + } + + return [ + 'type' => 'user_confirmation', + 'data' => [ + 'title' => __( 'User Confirmation Required', 'clawpress' ), + 'subtitle' => __( 'Destructive batch pending', 'clawpress' ), + 'message' => $message, + 'actions' => [ + [ + 'id' => 'confirm-batch-' . md5( $batch_id ), + 'label' => $total_calls > 1 + ? __( 'Confirm All', 'clawpress' ) + : __( 'Confirm Action', 'clawpress' ), + 'type' => 'send_prompt', + 'prompt' => '/confirm --batch=' . $batch_id, + ], + [ + 'id' => 'decline-batch-' . md5( $batch_id ), + 'label' => __( 'Decline', 'clawpress' ), + 'type' => 'send_prompt', + 'prompt' => '/decline --batch=' . $batch_id, + ], + ], + ], + ]; + } + + /** + * Normalize tool-call args. + * + * @param mixed $raw_args Raw args payload. + * @return array + */ + private function normalize_tool_args_for_prompt( $raw_args ): array { + if ( is_array( $raw_args ) ) { + return $raw_args; + } + + if ( is_object( $raw_args ) ) { + return (array) $raw_args; + } + + if ( ! is_string( $raw_args ) || '' === trim( $raw_args ) ) { + return []; + } + + $decoded = json_decode( $raw_args, true ); + return is_array( $decoded ) ? $decoded : []; + } + + /** + * Normalize history message list. + * + * @param array $context Model context payload. + * @return array + */ + private function normalize_history_messages( array $context ): array { + $history_messages = []; + if ( ! isset( $context['history_messages'] ) || ! is_array( $context['history_messages'] ) ) { + return $history_messages; + } + + foreach ( $context['history_messages'] as $history_message ) { + if ( $history_message instanceof Message ) { + $history_messages[] = $history_message; + } + } + + return $history_messages; + } + + /** + * Normalize function declarations from context payload. + * + * @param array $context Model context payload. + * @return array + */ + private function normalize_tool_declarations( array $context ): array { + $declarations = []; + if ( ! isset( $context['tool_declarations'] ) || ! is_array( $context['tool_declarations'] ) ) { + return $declarations; + } + + foreach ( $context['tool_declarations'] as $declaration ) { + if ( $declaration instanceof FunctionDeclaration ) { + $declarations[] = $declaration; + } + } + + return $declarations; + } + + /** + * Build request options object. + * + * @param int $request_timeout Request timeout in seconds. + */ + private function build_request_options( int $request_timeout ): RequestOptions { + $request_options = new RequestOptions(); + $request_options->setTimeout( (float) max( 1, $request_timeout ) ); + return $request_options; + } + + /** + * Normalize generation settings payload. + * + * @param array $generation_settings Raw generation settings. + * @return array{temperature:float,top_p:float,max_output_tokens:int,frequency_penalty:float,presence_penalty:float} + */ + private function normalize_generation_settings( array $generation_settings ): array { + return [ + 'temperature' => clawpress_sanitize_temperature( $generation_settings['temperature'] ?? 0.2 ), + 'top_p' => clawpress_sanitize_top_p( $generation_settings['top_p'] ?? 0.9 ), + 'max_output_tokens' => clawpress_sanitize_max_output_tokens( $generation_settings['max_output_tokens'] ?? 1200 ), + 'frequency_penalty' => clawpress_sanitize_frequency_penalty( $generation_settings['frequency_penalty'] ?? 0.2 ), + 'presence_penalty' => clawpress_sanitize_presence_penalty( $generation_settings['presence_penalty'] ?? 0.0 ), + ]; + } + + /** + * Apply generation settings to prompt builder. + * + * @param PromptBuilder $builder Prompt builder. + * @param array $generation_settings Generation settings. + * @param string $provider Provider identifier. + * @param string $model Model identifier. + */ + private function apply_generation_settings_to_prompt_builder( PromptBuilder $builder, array $generation_settings, string $provider, string $model ): PromptBuilder { + $option_setters = [ + static fn ( PromptBuilder $current ): PromptBuilder => $current->usingTemperature( (float) $generation_settings['temperature'] ), + static fn ( PromptBuilder $current ): PromptBuilder => $current->usingTopP( (float) $generation_settings['top_p'] ), + fn ( PromptBuilder $current ): PromptBuilder => $this->apply_max_output_tokens_to_prompt_builder( + $current, + (int) $generation_settings['max_output_tokens'], + $provider, + $model + ), + static fn ( PromptBuilder $current ): PromptBuilder => $current->usingFrequencyPenalty( (float) $generation_settings['frequency_penalty'] ), + static fn ( PromptBuilder $current ): PromptBuilder => $current->usingPresencePenalty( (float) $generation_settings['presence_penalty'] ), + ]; + + foreach ( $option_setters as $setter ) { + try { + $builder = $setter( $builder ); + } catch ( Throwable $throwable ) { + unset( $throwable ); + continue; + } + } + + return $builder; + } + + /** + * Apply max output token setting to prompt builder. + * + * @param PromptBuilder $builder Prompt builder. + * @param int $max_output_tokens Max output token count. + * @param string $provider Provider identifier. + * @param string $model Model identifier. + */ + private function apply_max_output_tokens_to_prompt_builder( PromptBuilder $builder, int $max_output_tokens, string $provider, string $model ): PromptBuilder { + if ( $this->provider_helper->should_use_max_completion_tokens( $provider, $model ) ) { + $model_config = ModelConfig::fromArray( + [ + ModelConfig::KEY_CUSTOM_OPTIONS => [ + 'max_completion_tokens' => $max_output_tokens, + ], + ] + ); + + return $builder->usingModelConfig( $model_config ); + } + + return $builder->usingMaxTokens( $max_output_tokens ); + } + + /** + * Extract function calls from a message. + * + * @param Message $message Model message. + * @return array + */ + private function extract_function_calls( Message $message ): array { + $function_calls = []; + + foreach ( $message->getParts() as $part ) { + $function_call = $part->getFunctionCall(); + if ( $function_call instanceof FunctionCall ) { + $function_calls[] = $function_call; + } + } + + return $function_calls; + } + + /** + * Extract text content from a message. + * + * @param Message $message Model message. + */ + private function extract_text_from_message( Message $message ): string { + $chunks = []; + + foreach ( $message->getParts() as $part ) { + $text = $part->getText(); + if ( null === $text || '' === trim( $text ) ) { + continue; + } + + $chunks[] = trim( $text ); + } + + return trim( implode( "\n", $chunks ) ); + } + + /** + * Classify known provider error patterns. + * + * @param Throwable $throwable Thrown exception. + * @param string $error_message Sanitized error message. + */ + public function classify_provider_error_type( Throwable $throwable, string $error_message ): string { + $message = strtolower( $error_message . ' ' . $throwable->getMessage() ); + $patterns = [ + 'timed out', + 'timeout', + 'curl error 28', + 'deadline exceeded', + 'operation timed out', + ]; + + foreach ( $patterns as $pattern ) { + if ( false !== strpos( $message, $pattern ) ) { + return 'timeout'; + } + } + + return 'provider'; + } + + /** + * Get current timestamp in milliseconds. + */ + private function now_ms(): int { + return (int) round( microtime( true ) * 1000 ); + } + + /** + * Invoke the online reply generator with arity-safe argument slicing. + * + * @param callable $online_reply_generator Online reply generator callback. + * @param array $context Model context. + * @param string $provider Provider ID. + * @param string $model Model ID. + * @param array $turn_request Turn request payload. + * @param Agent_Transport $transport Runtime transport. + * @param bool $is_slice Whether this is a slice execution. + * @return mixed + */ + private function invoke_online_reply_generator( + callable $online_reply_generator, + array $context, + string $provider, + string $model, + array $turn_request, + Agent_Transport $transport, + bool $is_slice + ) { + $args = [ $context, $provider, $model, $turn_request, $transport, $is_slice ]; + $arity = $this->resolve_callable_arity( $online_reply_generator ); + if ( $arity > 0 && $arity < count( $args ) ) { + $args = array_slice( $args, 0, $arity ); + } + + return call_user_func_array( $online_reply_generator, $args ); + } + + /** + * Resolve callable arity for safe callback invocation. + * + * @param callable $target_callable Callable to inspect. + */ + private function resolve_callable_arity( callable $target_callable ): int { + try { + if ( is_array( $target_callable ) ) { + $reflection = new \ReflectionMethod( $target_callable[0], (string) $target_callable[1] ); + return $reflection->isVariadic() ? 0 : $reflection->getNumberOfParameters(); + } + + if ( $target_callable instanceof \Closure || is_string( $target_callable ) ) { + $reflection = new \ReflectionFunction( $target_callable ); + return $reflection->isVariadic() ? 0 : $reflection->getNumberOfParameters(); + } + + if ( is_object( $target_callable ) && method_exists( $target_callable, '__invoke' ) ) { + $reflection = new \ReflectionMethod( $target_callable, '__invoke' ); + return $reflection->isVariadic() ? 0 : $reflection->getNumberOfParameters(); + } + + $reflection = new \ReflectionFunction( $target_callable ); + return $reflection->isVariadic() ? 0 : $reflection->getNumberOfParameters(); + } catch ( \ReflectionException | \TypeError $exception ) { + unset( $exception ); + return 0; + } + } +} diff --git a/includes/helpers/class-agent-run-helper.php b/includes/helpers/class-agent-run-helper.php index cf74e32..5be8ac9 100644 --- a/includes/helpers/class-agent-run-helper.php +++ b/includes/helpers/class-agent-run-helper.php @@ -22,7 +22,41 @@ final class Agent_Run_Helper { * * @var array */ - private const TERMINAL_STATUSES = [ 'success', 'failed', 'cancelled', 'canceled' ]; + private const TERMINAL_STATUSES = [ + 'success', + 'failed', + 'cancelled', + 'canceled', + 'done', + 'error', + 'timeout', + 'requires_confirmation', + ]; + + /** + * Statuses that can be manually re-enqueued. + * + * @var array + */ + private const ENQUEUEABLE_STATUSES = [ + 'success', + 'done', + 'failed', + 'cancelled', + 'canceled', + 'error', + 'timeout', + 'requires_confirmation', + ]; + + /** + * Pause reasons that represent retry/backoff state. + * + * @var array + */ + private const RETRY_PAUSE_REASONS = [ + 'retry_backoff', + ]; /** * Singleton instance. @@ -57,13 +91,137 @@ public static function get_instance(): self { } /** - * Create a queued run. + * Create a run row. + * + * @param int $session_id Parent session identifier. + * @param array $args Optional run args. + */ + public function create_run( int $session_id, array $args = [] ): int { + $idempotency_key = isset( $args['idempotency_key'] ) ? trim( (string) $args['idempotency_key'] ) : ''; + if ( $session_id > 0 && '' !== $idempotency_key ) { + $existing_run = $this->get_run_by_idempotency_key( $session_id, $idempotency_key ); + if ( [] !== $existing_run && isset( $existing_run['id'] ) ) { + return (int) $existing_run['id']; + } + } + + $now = gmdate( 'Y-m-d H:i:s' ); + $meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : []; + $meta_json = [] !== $meta ? wp_json_encode( $meta ) : null; + if ( false === $meta_json ) { + $meta_json = null; + } + + $resume_cursor_json = null; + if ( isset( $args['resume_cursor'] ) ) { + $encoded_resume = wp_json_encode( $args['resume_cursor'] ); + $resume_cursor_json = false === $encoded_resume ? null : $encoded_resume; + } + + $run_id = $this->store->insert_run( + [ + 'session_id' => $session_id, + 'run_uuid' => isset( $args['run_uuid'] ) ? (string) $args['run_uuid'] : $this->generate_uuid(), + 'trigger_type' => isset( $args['trigger_type'] ) ? (string) $args['trigger_type'] : 'chat', + 'transport_mode' => isset( $args['transport_mode'] ) ? (string) $args['transport_mode'] : 'polling', + 'status' => isset( $args['status'] ) ? (string) $args['status'] : 'queued', + 'attempt' => isset( $args['attempt'] ) ? max( 1, (int) $args['attempt'] ) : 1, + 'retry_count' => isset( $args['retry_count'] ) ? max( 0, (int) $args['retry_count'] ) : 0, + 'max_attempts' => isset( $args['max_attempts'] ) ? max( 1, (int) $args['max_attempts'] ) : 5, + 'next_retry_at_gmt' => isset( $args['next_retry_at_gmt'] ) ? (string) $args['next_retry_at_gmt'] : null, + 'resume_cursor_json' => $resume_cursor_json, + 'meta_json' => $meta_json, + 'idempotency_key' => isset( $args['idempotency_key'] ) ? (string) $args['idempotency_key'] : null, + 'created_at_gmt' => $now, + 'updated_at_gmt' => $now, + ] + ); + + if ( $run_id > 0 ) { + return $run_id; + } + + // Handle write races for idempotent run creation (duplicate-key insert in concurrent request). + if ( $session_id > 0 && '' !== $idempotency_key ) { + $existing_run = $this->get_run_by_idempotency_key( $session_id, $idempotency_key ); + if ( [] !== $existing_run && isset( $existing_run['id'] ) ) { + return (int) $existing_run['id']; + } + } + + return 0; + } + + /** + * Fetch one run by idempotency key. * - * @param int $session_id Parent session identifier. + * @param int $session_id Session identifier. + * @param string $idempotency_key Idempotency key. + * @return array */ - public function create_run( int $session_id ): int { - $now = gmdate( 'Y-m-d H:i:s' ); - return $this->store->insert_run( $session_id, $this->generate_uuid(), $now ); + public function get_run_by_idempotency_key( int $session_id, string $idempotency_key ): array { + $row = $this->store->get_run_by_idempotency_key( $session_id, $idempotency_key ); + if ( [] === $row ) { + return []; + } + + return $this->normalize_run_row( $row ); + } + + /** + * Re-enqueue an existing run. + * + * @param int $run_id Run identifier. + */ + public function enqueue_run( int $run_id ): bool { + $run = $this->get_run( $run_id ); + if ( [] === $run ) { + return false; + } + + $current_status = isset( $run['status'] ) ? (string) $run['status'] : 'queued'; + if ( ! $this->is_enqueueable_status( $current_status ) ) { + return false; + } + + $updated = $this->store->update_enqueue( $run_id, $current_status, gmdate( 'Y-m-d H:i:s' ) ); + return false !== $updated && $updated > 0; + } + + /** + * Claim next runnable run for a worker. + * + * @param string $worker_id Worker claim id. + * @param int $lease_ttl_seconds Lease TTL in seconds. + * @param int $scan_limit Max queued rows to scan. + * @return array + */ + public function claim_next_runnable_run( string $worker_id, int $lease_ttl_seconds = 120, int $scan_limit = 20 ): array { + $runnable_runs = $this->store->get_runnable_runs( $scan_limit, gmdate( 'Y-m-d H:i:s' ) ); + if ( [] === $runnable_runs ) { + return [ + 'claimed' => false, + 'reason' => 'no_runnable_runs', + ]; + } + + foreach ( $runnable_runs as $run ) { + $run_id = isset( $run['id'] ) ? (int) $run['id'] : 0; + if ( $run_id <= 0 ) { + continue; + } + + $claim = $this->claim_run( $run_id, $worker_id, $lease_ttl_seconds ); + if ( ! empty( $claim['claimed'] ) ) { + $claim['run'] = $this->get_run( $run_id ); + return $claim; + } + } + + return [ + 'claimed' => false, + 'reason' => 'claim_collision', + ]; } /** @@ -83,17 +241,18 @@ public function claim_run( int $run_id, string $worker_id, int $lease_ttl_second ]; } - $now = gmdate( 'Y-m-d H:i:s' ); - $lock_token = hash( 'sha256', uniqid( $worker_id . ':', true ) ); - $expires_at = gmdate( 'Y-m-d H:i:s', strtotime( $now ) + max( 1, $lease_ttl_seconds ) ); - - $is_stale = 'running' === (string) $run['status'] + $now = gmdate( 'Y-m-d H:i:s' ); + $lock_token = hash( 'sha256', uniqid( $worker_id . ':', true ) ); + $expires_at = gmdate( 'Y-m-d H:i:s', strtotime( $now ) + max( 1, $lease_ttl_seconds ) ); + $current_status = isset( $run['status'] ) ? (string) $run['status'] : 'queued'; + $is_stale = 'running' === $current_status && isset( $run['lock_expires_at_gmt'] ) && is_string( $run['lock_expires_at_gmt'] ) && '' !== $run['lock_expires_at_gmt'] && strtotime( $run['lock_expires_at_gmt'] ) < strtotime( $now ); + $is_claimable_now = in_array( $current_status, [ 'queued', 'paused' ], true ); - if ( 'queued' !== (string) $run['status'] && ! $is_stale ) { + if ( ! $is_claimable_now && ! $is_stale ) { return [ 'claimed' => false, 'reason' => 'not_claimable', @@ -101,13 +260,13 @@ public function claim_run( int $run_id, string $worker_id, int $lease_ttl_second } $next_attempt = (int) ( $run['attempt'] ?? 1 ); - if ( $is_stale ) { - ++$next_attempt; + if ( $this->should_increment_attempt_on_claim( $run, $is_stale, $current_status ) ) { + $next_attempt = max( 1, $next_attempt + 1 ); } $updated = $this->store->update_claim( $run_id, - (string) $run['status'], + $current_status, $is_stale ? (string) $run['lock_expires_at_gmt'] : null, [ 'status' => 'running', @@ -116,7 +275,9 @@ public function claim_run( int $run_id, string $worker_id, int $lease_ttl_second 'lock_acquired_at_gmt' => $now, 'lock_expires_at_gmt' => $expires_at, 'attempt' => $next_attempt, - 'started_at_gmt' => $now, + 'started_at_gmt' => isset( $run['started_at_gmt'] ) && null !== $run['started_at_gmt'] + ? (string) $run['started_at_gmt'] + : $now, 'updated_at_gmt' => $now, ], $is_stale @@ -138,6 +299,63 @@ public function claim_run( int $run_id, string $worker_id, int $lease_ttl_second ]; } + /** + * Persist paused/in-progress slice state for a claimed run. + * + * @param int $run_id Run identifier. + * @param string $lock_token Lock token from claim response. + * @param array $args Progress details. + */ + public function pause_run( int $run_id, string $lock_token, array $args = [] ): bool { + $run = $this->get_run( $run_id ); + if ( [] === $run || 'running' !== (string) $run['status'] ) { + return false; + } + + if ( (string) ( $run['lock_token'] ?? '' ) !== $lock_token ) { + return false; + } + + $resume_cursor_json = isset( $run['resume_cursor_json'] ) && is_string( $run['resume_cursor_json'] ) + ? $run['resume_cursor_json'] + : null; + if ( array_key_exists( 'resume_cursor', $args ) ) { + $encoded_resume = wp_json_encode( $args['resume_cursor'] ); + $resume_cursor_json = false === $encoded_resume ? null : $encoded_resume; + } + + $existing_meta = isset( $run['meta'] ) && is_array( $run['meta'] ) ? $run['meta'] : []; + $meta_json = isset( $run['meta_json'] ) && is_string( $run['meta_json'] ) + ? $run['meta_json'] + : null; + if ( isset( $args['meta'] ) && is_array( $args['meta'] ) ) { + $encoded_meta = wp_json_encode( array_merge( $existing_meta, $args['meta'] ) ); + $meta_json = false === $encoded_meta ? null : $encoded_meta; + } + + $updated = $this->store->update_progress( + $run_id, + $lock_token, + [ + 'status' => isset( $args['status'] ) ? (string) $args['status'] : 'paused', + 'next_retry_at_gmt' => isset( $args['next_retry_at_gmt'] ) ? (string) $args['next_retry_at_gmt'] : gmdate( 'Y-m-d H:i:s' ), + 'resume_cursor_json' => $resume_cursor_json, + 'meta_json' => $meta_json, + 'retry_count' => isset( $args['retry_count'] ) + ? max( 0, (int) $args['retry_count'] ) + : (int) ( $run['retry_count'] ?? 0 ), + 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + ] + ); + + if ( false === $updated || 0 === $updated ) { + return false; + } + + Agent_Session_Helper::get_instance()->apply_run_completion( (int) $run['session_id'], 'paused', isset( $args['next_retry_at_gmt'] ) ? (string) $args['next_retry_at_gmt'] : null ); + return true; + } + /** * Complete a run and update parent session state. * @@ -168,20 +386,28 @@ public function complete_run( int $run_id, string $lock_token, string $status, a $meta_json = null; if ( isset( $args['meta'] ) && is_array( $args['meta'] ) ) { - $encoded = wp_json_encode( $args['meta'] ); - $meta_json = false === $encoded ? null : $encoded; + $encoded_meta = wp_json_encode( $args['meta'] ); + $meta_json = false === $encoded_meta ? null : $encoded_meta; + } + + $resume_cursor_json = null; + if ( array_key_exists( 'resume_cursor', $args ) ) { + $encoded_resume = wp_json_encode( $args['resume_cursor'] ); + $resume_cursor_json = false === $encoded_resume ? null : $encoded_resume; } $updated = $this->store->update_completion( $run_id, $lock_token, [ - 'status' => $status, - 'finished_at_gmt' => gmdate( 'Y-m-d H:i:s' ), - 'error_code' => isset( $args['error_code'] ) ? (string) $args['error_code'] : null, - 'error_message' => isset( $args['error_message'] ) ? (string) $args['error_message'] : null, - 'meta_json' => $meta_json, - 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'status' => $status, + 'finished_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'next_retry_at_gmt' => isset( $args['next_retry_at_gmt'] ) ? (string) $args['next_retry_at_gmt'] : null, + 'resume_cursor_json' => $resume_cursor_json, + 'error_code' => isset( $args['error_code'] ) ? (string) $args['error_code'] : null, + 'error_message' => isset( $args['error_message'] ) ? (string) $args['error_message'] : null, + 'meta_json' => $meta_json, + 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), ] ); @@ -216,7 +442,105 @@ public function complete_run( int $run_id, string $lock_token, string $status, a * @return array */ public function get_run( int $run_id ): array { - return $this->store->get_run( $run_id ); + $row = $this->store->get_run( $run_id ); + if ( [] === $row ) { + return []; + } + + return $this->normalize_run_row( $row ); + } + + /** + * Build status summary for run polling. + * + * @param int $run_id Run identifier. + * @return array + */ + public function get_run_status_summary( int $run_id ): array { + $run = $this->get_run( $run_id ); + if ( [] === $run ) { + return []; + } + + return [ + 'run_id' => isset( $run['id'] ) ? (int) $run['id'] : 0, + 'session_id' => isset( $run['session_id'] ) ? (int) $run['session_id'] : 0, + 'run_uuid' => isset( $run['run_uuid'] ) ? (string) $run['run_uuid'] : '', + 'status' => isset( $run['status'] ) ? (string) $run['status'] : 'queued', + 'trigger_type' => isset( $run['trigger_type'] ) ? (string) $run['trigger_type'] : 'chat', + 'transport_mode' => isset( $run['transport_mode'] ) ? (string) $run['transport_mode'] : 'polling', + 'attempt' => isset( $run['attempt'] ) ? (int) $run['attempt'] : 1, + 'retry_count' => isset( $run['retry_count'] ) ? (int) $run['retry_count'] : 0, + 'max_attempts' => isset( $run['max_attempts'] ) ? (int) $run['max_attempts'] : 5, + 'next_retry_at_gmt' => $run['next_retry_at_gmt'] ?? null, + 'started_at_gmt' => $run['started_at_gmt'] ?? null, + 'finished_at_gmt' => $run['finished_at_gmt'] ?? null, + 'error_code' => $run['error_code'] ?? null, + 'error_message' => $run['error_message'] ?? null, + 'resume_cursor' => $run['resume_cursor'] ?? null, + 'meta' => $run['meta'] ?? null, + ]; + } + + /** + * Check whether a run status can be re-enqueued manually. + * + * @param string $status Run status. + */ + public function is_enqueueable_status( string $status ): bool { + return in_array( strtolower( trim( $status ) ), self::ENQUEUEABLE_STATUSES, true ); + } + + /** + * Normalize JSON-backed run fields. + * + * @param array $row Raw DB row. + * @return array + */ + private function normalize_run_row( array $row ): array { + if ( ! isset( $row['retry_count'] ) ) { + $row['retry_count'] = 0; + } + + if ( isset( $row['resume_cursor_json'] ) && is_string( $row['resume_cursor_json'] ) && '' !== trim( $row['resume_cursor_json'] ) ) { + $decoded = json_decode( $row['resume_cursor_json'], true ); + if ( is_array( $decoded ) ) { + $row['resume_cursor'] = $decoded; + } + } + + if ( isset( $row['meta_json'] ) && is_string( $row['meta_json'] ) && '' !== trim( $row['meta_json'] ) ) { + $decoded = json_decode( $row['meta_json'], true ); + if ( is_array( $decoded ) ) { + $row['meta'] = $decoded; + } + } + + return $row; + } + + /** + * Determine whether a new claim should increment run attempt. + * + * @param array $run Run row. + * @param bool $is_stale True when reclaiming stale running lock. + * @param string $current_status Current persisted run status. + */ + private function should_increment_attempt_on_claim( array $run, bool $is_stale, string $current_status ): bool { + if ( $is_stale ) { + return true; + } + + if ( 'paused' !== $current_status ) { + return false; + } + + $pause_reason = ''; + if ( isset( $run['meta'] ) && is_array( $run['meta'] ) && isset( $run['meta']['pause_reason'] ) ) { + $pause_reason = strtolower( trim( (string) $run['meta']['pause_reason'] ) ); + } + + return in_array( $pause_reason, self::RETRY_PAUSE_REASONS, true ); } /** diff --git a/includes/helpers/class-agent-session-helper.php b/includes/helpers/class-agent-session-helper.php index 3946a2e..a08e510 100644 --- a/includes/helpers/class-agent-session-helper.php +++ b/includes/helpers/class-agent-session-helper.php @@ -60,7 +60,7 @@ public function create_session( array $args = [] ): int { return $this->store->insert_session( [ 'uuid' => isset( $args['uuid'] ) ? (string) $args['uuid'] : $this->generate_uuid(), - 'status' => isset( $args['status'] ) ? (string) $args['status'] : 'active', + 'status' => isset( $args['status'] ) ? (string) $args['status'] : 'idle', 'trigger_type' => isset( $args['trigger_type'] ) ? (string) $args['trigger_type'] : 'chat', 'requesting_user_id' => isset( $args['requesting_user_id'] ) ? (int) $args['requesting_user_id'] : null, 'execution_user_id' => isset( $args['execution_user_id'] ) ? (int) $args['execution_user_id'] : null, @@ -75,6 +75,101 @@ public function create_session( array $args = [] ): int { ); } + /** + * Fetch one session row by ID. + * + * @param int $session_id Session identifier. + * @return array + */ + public function get_session( int $session_id ): array { + return $this->store->get_session( $session_id ); + } + + /** + * Claim session lease for one worker. + * + * @param int $session_id Session identifier. + * @param string $worker_id Worker claim id. + * @param int $lease_ttl_seconds Lease TTL in seconds. + * @return array + */ + public function claim_session( int $session_id, string $worker_id, int $lease_ttl_seconds = 120 ): array { + $session = $this->get_session( $session_id ); + if ( [] === $session ) { + return [ + 'claimed' => false, + 'reason' => 'session_not_found', + ]; + } + + $current_status = isset( $session['status'] ) ? (string) $session['status'] : 'idle'; + $now = gmdate( 'Y-m-d H:i:s' ); + $is_stale = 'running' === $current_status + && isset( $session['lease_expires_at_gmt'] ) + && is_string( $session['lease_expires_at_gmt'] ) + && '' !== $session['lease_expires_at_gmt'] + && strtotime( $session['lease_expires_at_gmt'] ) < strtotime( $now ); + $is_claimable_now = in_array( $current_status, [ 'idle', 'paused' ], true ); + + if ( ! $is_claimable_now && ! $is_stale ) { + return [ + 'claimed' => false, + 'reason' => 'not_claimable', + ]; + } + + $lease_token = hash( 'sha256', uniqid( $worker_id . ':session:', true ) ); + $expires_at = gmdate( 'Y-m-d H:i:s', strtotime( $now ) + max( 1, $lease_ttl_seconds ) ); + $updated = $this->store->update_claim( + $session_id, + $current_status, + $is_stale ? (string) $session['lease_expires_at_gmt'] : null, + [ + 'status' => 'running', + 'lease_owner' => $worker_id, + 'lease_token' => $lease_token, + 'lease_acquired_at_gmt' => $now, + 'lease_expires_at_gmt' => $expires_at, + 'updated_at_gmt' => $now, + ], + $is_stale + ); + + if ( false === $updated || 0 === $updated ) { + return [ + 'claimed' => false, + 'reason' => 'claim_collision', + ]; + } + + return [ + 'claimed' => true, + 'session_id' => $session_id, + 'lease_token' => $lease_token, + 'reclaimed' => $is_stale, + ]; + } + + /** + * Release session lease. + * + * @param int $session_id Session identifier. + * @param string $lease_token Lease token from claim response. + * @param string $status New session status. + */ + public function release_session( int $session_id, string $lease_token, string $status = 'idle' ): bool { + $updated = $this->store->update_release( + $session_id, + $lease_token, + [ + 'status' => $status, + 'updated_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + ] + ); + + return false !== $updated && $updated > 0; + } + /** * Update parent session state after run completion. * diff --git a/includes/helpers/class-chat-helper.php b/includes/helpers/class-chat-helper.php index 9a9f216..407ef97 100644 --- a/includes/helpers/class-chat-helper.php +++ b/includes/helpers/class-chat-helper.php @@ -9,19 +9,9 @@ namespace ClawPress\Helpers; -use ClawPress\Commands\Command_Confirmation_Store; use ClawPress\Commands\Commands; +use ClawPress\Runner\Agent_Runner; use Throwable; -use WordPress\AiClient\AiClient; -use WordPress\AiClient\Builders\MessageBuilder; -use WordPress\AiClient\Builders\PromptBuilder; -use WordPress\AiClient\Messages\DTO\Message; -use WordPress\AiClient\Providers\Http\DTO\RequestOptions; -use WordPress\AiClient\Providers\Models\DTO\ModelConfig; -use WordPress\AiClient\Results\DTO\GenerativeAiResult; -use WordPress\AiClient\Tools\DTO\FunctionCall; -use WordPress\AiClient\Tools\DTO\FunctionDeclaration; -use WordPress\AiClient\Tools\DTO\FunctionResponse; defined( 'ABSPATH' ) || exit; @@ -29,41 +19,6 @@ * Reply generation helper. */ final class Chat_Helper { - /** - * Maximum tool-calling rounds per user message. - */ - private const MAX_TOOL_ROUNDS = 4; - - /** - * Maximum tool calls executed in one assistant turn. - */ - private const MAX_TOOL_CALLS_PER_ROUND = 6; - - /** - * Known context windows for common model families. - * - * @var array> - */ - private const MODEL_CONTEXT_WINDOWS = [ - 'openai' => [ - 'gpt-4.1-mini' => 1048576, - 'gpt-4.1' => 1048576, - 'gpt-4o-mini' => 128000, - 'gpt-4o' => 128000, - 'gpt-5' => 400000, - 'o3' => 200000, - 'o1' => 200000, - ], - 'anthropic' => [ - 'claude-' => 200000, - ], - 'google' => [ - 'gemini-2.5' => 1048576, - 'gemini-2.0' => 1048576, - 'gemini-1.5' => 1048576, - ], - ]; - /** * Singleton instance. * @@ -86,32 +41,11 @@ final class Chat_Helper { private Provider_Helper $provider_helper; /** - * Context helper. - * - * @var Context_Helper - */ - private Context_Helper $context_helper; - - /** - * Abilities helper. - * - * @var Abilities_Helper - */ - private Abilities_Helper $abilities_helper; - - /** - * Confirmation store. - * - * @var Command_Confirmation_Store - */ - private Command_Confirmation_Store $confirmation_store; - - /** - * Policy helper. + * Agent loop runtime helper. * - * @var Policy_Helper + * @var Agent_Loop_Helper */ - private Policy_Helper $policy_helper; + private Agent_Loop_Helper $agent_loop_helper; /** * LLM reply generator. @@ -130,7 +64,7 @@ final class Chat_Helper { /** * Constructor. * - * @param Context_Helper|null $context_helper Optional context helper. + * @param Context_Helper|null $context_helper Optional context helper (reserved for compatibility). * @param callable|null $online_reply_generator Optional online reply generator. * @param callable|null $provider_model_resolver Optional provider/model resolver. */ @@ -139,14 +73,13 @@ private function __construct( ?callable $online_reply_generator = null, ?callable $provider_model_resolver = null ) { + unset( $context_helper ); + $this->settings_helper = Settings_Helper::get_instance(); $this->provider_helper = Provider_Helper::get_instance(); - $this->context_helper = $context_helper ?? Context_Helper::get_instance(); - $this->abilities_helper = Abilities_Helper::get_instance(); - $this->confirmation_store = new Command_Confirmation_Store(); - $this->policy_helper = Policy_Helper::get_instance(); - $this->online_reply_generator = $online_reply_generator ?? [ $this, 'generate_online_reply' ]; - $this->provider_model_resolver = $provider_model_resolver ?? [ $this, 'resolve_provider_and_model' ]; + $this->agent_loop_helper = Agent_Loop_Helper::get_instance(); + $this->online_reply_generator = $online_reply_generator; + $this->provider_model_resolver = $provider_model_resolver ?? [ $this->provider_helper, 'resolve_provider_and_model' ]; } /** @@ -197,22 +130,134 @@ public function generate_ai_reply( string $message ): array { ]; } + $session_id = 0; + $run_id = 0; + $run_lock_token = ''; + $session_lease_token = ''; + try { - $context = $this->context_helper->build_model_context( $message ); - $context['request_timeout'] = $this->settings_helper->get_request_timeout( $settings ); - $context['generation_settings'] = $this->settings_helper->get_generation_settings( $settings ); - $online_reply_payload = $this->normalize_online_reply_payload( - call_user_func( - $this->online_reply_generator, - $context, + $slice_budget_ms = $this->resolve_chat_slice_budget_ms( $settings ); + $requesting_user_id = function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0; + $execution_user_id = $this->resolve_execution_user_id( $settings, $requesting_user_id ); + $has_persistent_run = false; + $turn_request = [ + 'message' => $message, + 'trigger' => 'chat', + 'transport_mode' => 'polling', + 'slice_budget_ms' => $slice_budget_ms, + 'max_steps_per_slice' => 2, + 'requesting_user_id' => $requesting_user_id, + 'execution_user_id' => $execution_user_id, + 'provider_model_resolver' => $this->provider_model_resolver, + ]; + + $session_id = Agent_Session_Helper::get_instance()->create_session( + [ + 'trigger_type' => 'chat', + 'requesting_user_id' => $requesting_user_id > 0 ? $requesting_user_id : null, + 'execution_user_id' => $execution_user_id > 0 ? $execution_user_id : null, + 'policy_profile' => 'default', + ] + ); + + if ( $session_id > 0 ) { + $run_id = Agent_Run_Helper::get_instance()->create_run( + $session_id, + [ + 'trigger_type' => 'chat', + 'transport_mode' => 'polling', + 'status' => 'queued', + 'meta' => [ + 'message' => $message, + 'slice_budget_ms' => $slice_budget_ms, + 'max_steps_per_slice' => 2, + ], + ] + ); + } + + $run_claim = []; + if ( $run_id > 0 ) { + $worker_id = $this->build_chat_worker_id(); + $run_claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, $worker_id, 120 ); + if ( ! empty( $run_claim['claimed'] ) && isset( $run_claim['lock_token'] ) ) { + $run_lock_token = (string) $run_claim['lock_token']; + $session_claim = Agent_Session_Helper::get_instance()->claim_session( $session_id, $worker_id, 120 ); + if ( ! empty( $session_claim['claimed'] ) && isset( $session_claim['lease_token'] ) ) { + $session_lease_token = (string) $session_claim['lease_token']; + $has_persistent_run = true; + $turn_request['run_id'] = $run_id; + $turn_request['session_id'] = $session_id; + $turn_request['attempt'] = isset( $run_claim['attempt'] ) ? (int) $run_claim['attempt'] : 1; + } + } + } + + if ( ! $has_persistent_run ) { + $session_id = 0; + $run_id = 0; + $run_lock_token = ''; + $session_lease_token = ''; + } + + if ( null !== $this->online_reply_generator ) { + $turn_request['online_reply_generator'] = $this->online_reply_generator; + } + + $runtime_result = $this->agent_loop_helper->run_slice( $turn_request ); + + if ( 'in_progress' === (string) ( $runtime_result['status'] ?? '' ) ) { + return $this->build_in_progress_reply_payload( $provider, - $model - ) + $model, + $run_id, + $session_id, + $run_lock_token, + $session_lease_token, + $turn_request, + $runtime_result + ); + } + + if ( isset( $runtime_result['error'] ) && is_array( $runtime_result['error'] ) ) { + $run_status = isset( $runtime_result['status'] ) && 'timeout' === (string) $runtime_result['status'] + ? 'timeout' + : 'error'; + Agent_Run_Helper::get_instance()->complete_run( + $run_id, + $run_lock_token, + $run_status, + [ + 'error_code' => isset( $runtime_result['error']['type'] ) ? (string) $runtime_result['error']['type'] : 'run_failed', + 'error_message' => isset( $runtime_result['error']['message'] ) ? (string) $runtime_result['error']['message'] : __( 'Agent run failed.', 'clawpress' ), + 'meta' => [ + 'result' => $runtime_result, + ], + ] + ); + Agent_Session_Helper::get_instance()->release_session( $session_id, $session_lease_token, 'error' ); + return $this->build_runtime_error_reply_payload( $runtime_result['error'], $provider, $model ); + } + + $run_terminal_status = 'requires_confirmation' === (string) ( $runtime_result['status'] ?? '' ) + ? 'requires_confirmation' + : 'done'; + Agent_Run_Helper::get_instance()->complete_run( + $run_id, + $run_lock_token, + $run_terminal_status, + [ + 'meta' => [ + 'result' => $runtime_result, + ], + ] ); - $reply = trim( (string) $online_reply_payload['reply'] ); - $card = $online_reply_payload['card']; - $context_usage = $online_reply_payload['context']; - $tool_calls = $online_reply_payload['tool_calls']; + Agent_Session_Helper::get_instance()->release_session( $session_id, $session_lease_token, 'idle' ); + + $reply = isset( $runtime_result['assistant_text'] ) ? trim( (string) $runtime_result['assistant_text'] ) : ''; + $card = isset( $runtime_result['card'] ) && is_array( $runtime_result['card'] ) ? $runtime_result['card'] : null; + $context_usage = isset( $runtime_result['context'] ) && is_array( $runtime_result['context'] ) ? $runtime_result['context'] : null; + $tool_calls = isset( $runtime_result['tool_calls'] ) && is_array( $runtime_result['tool_calls'] ) ? $runtime_result['tool_calls'] : []; if ( '' === $reply && null === $card ) { return [ @@ -248,432 +293,158 @@ public function generate_ai_reply( string $message ): array { $payload['tool_calls'] = $tool_calls; } - return $payload; - } catch ( Throwable $throwable ) { - return $this->build_error_reply_payload( $throwable, $provider, $model ); - } - } - - /** - * Default online reply generator using php-ai-client. - * - * @param array $context Model context payload. - * @param string $provider Provider identifier. - * @param string $model Model identifier. - * @return array{ - * reply:string, - * card:array|null, - * context:array|null, - * tool_calls:array> - * } - */ - private function generate_online_reply( array $context, string $provider, string $model ): array { - $current_message = isset( $context['message'] ) ? trim( (string) $context['message'] ) : ''; - $system_prompt = isset( $context['system_prompt'] ) ? trim( (string) $context['system_prompt'] ) : ''; - $request_timeout = isset( $context['request_timeout'] ) ? (int) $context['request_timeout'] : 45; - $generation_settings = isset( $context['generation_settings'] ) && is_array( $context['generation_settings'] ) - ? $this->normalize_generation_settings( $context['generation_settings'] ) - : $this->settings_helper->get_generation_settings(); - $history_messages = $this->normalize_history_messages( $context ); - $tool_declarations = $this->normalize_tool_declarations( $context ); - $requesting_user_id = isset( $context['requesting_user_id'] ) ? (int) $context['requesting_user_id'] : 0; - $execution_user_id = isset( $context['execution_user_id'] ) ? (int) $context['execution_user_id'] : 0; - $trigger_type = isset( $context['trigger_type'] ) ? (string) $context['trigger_type'] : 'chat'; - $runtime_policy = $this->policy_helper->resolve_runtime_policy( - $trigger_type, - isset( $context['session_metadata'] ) && is_array( $context['session_metadata'] ) - ? $context['session_metadata'] - : [], - isset( $context['policy_overrides'] ) && is_array( $context['policy_overrides'] ) - ? $context['policy_overrides'] - : [] - ); - $this->confirmation_store->clear_tool_batch( $requesting_user_id > 0 ? $requesting_user_id : null ); - - $conversation = $history_messages; - if ( '' !== $current_message ) { - $conversation[] = ( new MessageBuilder( $current_message ) ) - ->usingUserRole() - ->get(); - } - - $latest_assistant_text = ''; - $confirmation_batch = []; - $latest_context_usage = null; - $tool_call_trace = []; - - for ( $round = 0; $round < (int) $runtime_policy['max_tool_rounds']; ++$round ) { - $builder = AiClient::prompt( $conversation )->usingProvider( $provider ); - if ( '' !== $system_prompt ) { - $builder = $builder->usingSystemInstruction( $system_prompt ); - } - - if ( '' !== $model ) { - $builder = $builder->usingModelPreference( [ $provider, $model ] ); - } - - $builder = $builder->usingRequestOptions( $this->build_request_options( $request_timeout ) ); - $builder = $this->apply_generation_settings_to_prompt_builder( $builder, $generation_settings, $provider, $model ); - if ( [] !== $tool_declarations ) { - $builder = $builder->usingFunctionDeclarations( ...$tool_declarations ); - } - - $result = $builder->generateResult(); - $assistant_message = $result->toMessage(); - $conversation[] = $assistant_message; - $latest_assistant_text = $this->extract_text_from_message( $assistant_message ); - $latest_context_usage = $this->extract_context_usage_from_result( $result, $provider, $model ); - - $function_calls = $this->extract_function_calls( $assistant_message ); - if ( [] === $function_calls ) { - break; + if ( isset( $runtime_result['events_cursor'] ) ) { + $payload['events_cursor'] = (int) $runtime_result['events_cursor']; } - $function_responses = []; - foreach ( array_slice( $function_calls, 0, (int) $runtime_policy['max_tool_calls_per_round'] ) as $index => $function_call ) { - $tool_name = trim( (string) $function_call->getName() ); - if ( '' === $tool_name ) { - continue; - } - - $function_response_id = trim( (string) $function_call->getId() ); - if ( '' === $function_response_id ) { - $function_response_id = sprintf( 'tool-call-%d-%d', $round + 1, $index + 1 ); - } - - $tool_result = $this->abilities_helper->execute_tool_call( - $tool_name, - $function_call->getArgs(), + return $payload; + } catch ( Throwable $throwable ) { + if ( $run_id > 0 && '' !== $run_lock_token ) { + Agent_Run_Helper::get_instance()->complete_run( + $run_id, + $run_lock_token, + 'error', [ - 'requesting_user_id' => $requesting_user_id, - 'execution_user_id' => $execution_user_id, - 'confirmation_scope' => 'batch', - 'trigger_type' => $trigger_type, - 'runtime_policy' => $runtime_policy, + 'error_code' => 'chat_runtime_exception', + 'error_message' => trim( sanitize_text_field( $throwable->getMessage() ) ), + 'meta' => [ + 'exception' => get_class( $throwable ), + ], ] ); - $tool_call_trace[] = $this->build_tool_call_trace_entry( - $tool_name, - $function_call->getArgs(), - $tool_result, - $round + 1, - $index + 1 - ); - - $pending_confirmation = $this->normalize_pending_confirmation_tool_call( - $tool_result, - $tool_name, - $function_call->getArgs() - ); - if ( is_array( $pending_confirmation ) ) { - $confirmation_batch[] = $pending_confirmation; - } - - $function_responses[] = new FunctionResponse( - $function_response_id, - $tool_name, - $tool_result - ); } - if ( [] === $function_responses ) { - break; + if ( $session_id > 0 && '' !== $session_lease_token ) { + Agent_Session_Helper::get_instance()->release_session( $session_id, $session_lease_token, 'error' ); } - if ( [] !== $confirmation_batch ) { - $issued_batch = $this->confirmation_store->issue_tool_batch( - $confirmation_batch, - $requesting_user_id > 0 ? $requesting_user_id : null - ); - - return [ - 'reply' => $latest_assistant_text, - 'card' => $this->build_tool_confirmation_card( $issued_batch ), - 'context' => $latest_context_usage, - 'tool_calls' => $tool_call_trace, - ]; - } - - foreach ( $function_responses as $function_response ) { - $tool_response_message_builder = new MessageBuilder(); - $tool_response_message_builder->usingUserRole(); - $tool_response_message_builder->withFunctionResponse( $function_response ); - $conversation[] = $tool_response_message_builder->get(); - } + return $this->build_error_reply_payload( $throwable, $provider, $model ); } - - return [ - 'reply' => $latest_assistant_text, - 'card' => null, - 'context' => $latest_context_usage, - 'tool_calls' => $tool_call_trace, - ]; } /** - * Normalize online generator output into reply + optional card payload. + * Build chat payload for in-progress continuation via background runner. * - * @param mixed $raw_output Raw callback output. - * @return array{ - * reply:string, - * card:array|null, - * context:array|null, - * tool_calls:array> - * } + * @param string $provider Provider identifier. + * @param string $model Model identifier. + * @param int $run_id Run identifier. + * @param int $session_id Session identifier. + * @param string $run_lock_token Claimed run lock token. + * @param string $session_lease_token Claimed session lease token. + * @param array $turn_request Turn request payload. + * @param array $runtime_result Runtime result payload. + * @return array */ - private function normalize_online_reply_payload( $raw_output ): array { - if ( is_array( $raw_output ) ) { - $reply = isset( $raw_output['reply'] ) ? (string) $raw_output['reply'] : ''; - $card = isset( $raw_output['card'] ) && is_array( $raw_output['card'] ) - ? $this->normalize_card_payload( $raw_output['card'] ) - : null; - $context = isset( $raw_output['context'] ) && is_array( $raw_output['context'] ) - ? $this->normalize_context_usage_payload( $raw_output['context'] ) - : null; - $tool_calls = isset( $raw_output['tool_calls'] ) && is_array( $raw_output['tool_calls'] ) - ? $this->normalize_tool_call_trace_payload( $raw_output['tool_calls'] ) - : []; - + private function build_in_progress_reply_payload( + string $provider, + string $model, + int $run_id, + int $session_id, + string $run_lock_token, + string $session_lease_token, + array $turn_request, + array $runtime_result + ): array { + $pause_saved = Agent_Run_Helper::get_instance()->pause_run( + $run_id, + $run_lock_token, + [ + 'status' => 'paused', + 'next_retry_at_gmt' => gmdate( 'Y-m-d H:i:s' ), + 'resume_cursor' => $runtime_result['resume_cursor'] ?? null, + 'meta' => [ + 'message' => isset( $turn_request['message'] ) ? (string) $turn_request['message'] : '', + 'slice_budget_ms' => isset( $turn_request['slice_budget_ms'] ) ? (int) $turn_request['slice_budget_ms'] : 1500, + 'max_steps_per_slice' => isset( $turn_request['max_steps_per_slice'] ) ? (int) $turn_request['max_steps_per_slice'] : 2, + 'last_result' => $runtime_result, + 'pause_reason' => 'chat_request_budget', + ], + ] + ); + if ( ! $pause_saved ) { return [ - 'reply' => $reply, - 'card' => $card, - 'context' => $context, - 'tool_calls' => $tool_calls, + 'reply' => __( 'Unable to persist paused run state.', 'clawpress' ), + 'mode' => 'error', + 'provider' => $provider, + 'model' => '' !== $model ? $model : null, + 'suggestions' => $this->get_default_offline_suggestions(), ]; } - return [ - 'reply' => (string) $raw_output, - 'card' => null, - 'context' => null, - 'tool_calls' => [], - ]; - } - - /** - * Build a compact trace row for a single tool call. - * - * @param string $tool_name Tool name. - * @param mixed $raw_args Tool arguments. - * @param array $tool_result Tool result payload. - * @param int $round Tool round number. - * @param int $sequence Tool sequence in round. - * @return array - */ - private function build_tool_call_trace_entry( - string $tool_name, - $raw_args, - array $tool_result, - int $round, - int $sequence - ): array { - $normalized_tool_name = strtolower( trim( $tool_name ) ); - $ability_name = isset( $tool_result['ability'] ) ? trim( (string) $tool_result['ability'] ) : ''; - $requires_confirmation = isset( $tool_result['requires_confirmation'] ) && true === $tool_result['requires_confirmation']; - $success = isset( $tool_result['success'] ) && true === $tool_result['success']; - $status = $success ? 'success' : ( $requires_confirmation ? 'requires_confirmation' : 'error' ); - $message = ''; - - if ( isset( $tool_result['error']['message'] ) ) { - $message = trim( sanitize_text_field( (string) $tool_result['error']['message'] ) ); - } elseif ( isset( $tool_result['result']['message'] ) ) { - $message = trim( sanitize_text_field( (string) $tool_result['result']['message'] ) ); + if ( ! Agent_Session_Helper::get_instance()->release_session( $session_id, $session_lease_token, 'paused' ) ) { + return [ + 'reply' => __( 'Unable to release session after pausing run.', 'clawpress' ), + 'mode' => 'error', + 'provider' => $provider, + 'model' => '' !== $model ? $model : null, + 'suggestions' => $this->get_default_offline_suggestions(), + ]; } - if ( '' !== $message && strlen( $message ) > 200 ) { - $message = substr( $message, 0, 197 ) . '...'; + Agent_Runner::enqueue_run_slice_action( $run_id ); + + $reply = isset( $runtime_result['assistant_text'] ) ? trim( (string) $runtime_result['assistant_text'] ) : ''; + if ( '' === $reply ) { + $reply = __( 'I am still working on this', 'clawpress' ); } - return [ - 'name' => $normalized_tool_name, - 'ability' => '' !== $ability_name ? $ability_name : null, - 'args' => $this->normalize_tool_args_for_prompt( $raw_args ), - 'status' => $status, - 'requires_confirmation' => $requires_confirmation, - 'message' => '' !== $message ? $message : null, - 'round' => max( 1, $round ), - 'sequence' => max( 1, $sequence ), + $payload = [ + 'reply' => $reply, + 'mode' => 'in_progress', + 'status' => 'in_progress', + 'provider' => $provider, + 'model' => '' !== $model ? $model : null, + 'suggestions' => [], + 'run_id' => $run_id, + 'session_id' => $session_id, ]; - } - - /** - * Normalize tool call trace payload. - * - * @param array $tool_calls Raw trace payload. - * @return array> - */ - private function normalize_tool_call_trace_payload( array $tool_calls ): array { - $normalized = []; - foreach ( $tool_calls as $index => $tool_call ) { - if ( ! is_array( $tool_call ) ) { - continue; - } - - $name = isset( $tool_call['name'] ) ? strtolower( trim( sanitize_text_field( (string) $tool_call['name'] ) ) ) : ''; - if ( '' === $name ) { - continue; - } - - $ability = isset( $tool_call['ability'] ) ? trim( sanitize_text_field( (string) $tool_call['ability'] ) ) : ''; - $args = isset( $tool_call['args'] ) ? $this->normalize_tool_args_for_prompt( $tool_call['args'] ) : []; - $status = isset( $tool_call['status'] ) ? strtolower( trim( (string) $tool_call['status'] ) ) : 'success'; - $status = in_array( $status, [ 'success', 'error', 'requires_confirmation' ], true ) ? $status : 'success'; - $message = isset( $tool_call['message'] ) ? trim( sanitize_text_field( (string) $tool_call['message'] ) ) : ''; - $round = isset( $tool_call['round'] ) ? (int) $tool_call['round'] : 1; - $sequence = isset( $tool_call['sequence'] ) ? (int) $tool_call['sequence'] : ( $index + 1 ); - - $normalized[] = [ - 'name' => $name, - 'ability' => '' !== $ability ? $ability : null, - 'args' => $args, - 'status' => $status, - 'requires_confirmation' => isset( $tool_call['requires_confirmation'] ) ? (bool) $tool_call['requires_confirmation'] : ( 'requires_confirmation' === $status ), - 'message' => '' !== $message ? $message : null, - 'round' => max( 1, $round ), - 'sequence' => max( 1, $sequence ), - ]; + if ( isset( $runtime_result['events_cursor'] ) ) { + $payload['events_cursor'] = (int) $runtime_result['events_cursor']; } - return $normalized; - } - - /** - * Build normalized context usage metadata from a provider result. - * - * @param GenerativeAiResult $result Result payload. - * @param string $provider Provider identifier. - * @param string $model Model identifier. - * @return array|null - */ - private function extract_context_usage_from_result( GenerativeAiResult $result, string $provider, string $model ): ?array { - $token_usage = $result->getTokenUsage(); - $prompt_tokens = max( 0, (int) $token_usage->getPromptTokens() ); - $completion_tokens = max( 0, (int) $token_usage->getCompletionTokens() ); - $total_tokens = max( 0, (int) $token_usage->getTotalTokens() ); - $context_used = $prompt_tokens > 0 ? $prompt_tokens : $total_tokens; - $context_window = $this->resolve_context_window_tokens( $provider, $model ); - $percent_used = null; - $percent_left = null; - $window_is_estimate = null; - - if ( $context_window > 0 ) { - $percent_used = (int) round( min( 100, ( $context_used / $context_window ) * 100 ) ); - $percent_left = max( 0, 100 - $percent_used ); - $window_is_estimate = true; + if ( isset( $runtime_result['context'] ) && is_array( $runtime_result['context'] ) ) { + $payload['context'] = $runtime_result['context']; } - if ( 0 === $prompt_tokens && 0 === $completion_tokens && 0 === $total_tokens && null === $percent_used ) { - return null; + if ( isset( $runtime_result['tool_calls'] ) && is_array( $runtime_result['tool_calls'] ) && [] !== $runtime_result['tool_calls'] ) { + $payload['tool_calls'] = $runtime_result['tool_calls']; } - return [ - 'prompt_tokens' => $prompt_tokens, - 'completion_tokens' => $completion_tokens, - 'total_tokens' => $total_tokens, - 'used_tokens' => $context_used, - 'context_window_tokens' => $context_window > 0 ? $context_window : null, - 'percent_used' => $percent_used, - 'percent_left' => $percent_left, - 'window_is_estimated' => $window_is_estimate, - ]; + return $payload; } /** - * Normalize context usage payload. + * Resolve synchronous chat slice budget. * - * @param array $context Raw context payload. - * @return array|null + * @param array $settings Plugin settings. */ - private function normalize_context_usage_payload( array $context ): ?array { - $prompt_tokens = isset( $context['prompt_tokens'] ) ? (int) $context['prompt_tokens'] : 0; - $completion_tokens = isset( $context['completion_tokens'] ) ? (int) $context['completion_tokens'] : 0; - $total_tokens = isset( $context['total_tokens'] ) ? (int) $context['total_tokens'] : 0; - $used_tokens = isset( $context['used_tokens'] ) ? (int) $context['used_tokens'] : max( 0, $prompt_tokens ); - $context_window = isset( $context['context_window_tokens'] ) ? (int) $context['context_window_tokens'] : 0; - $percent_used = isset( $context['percent_used'] ) ? (int) $context['percent_used'] : null; - $percent_left = isset( $context['percent_left'] ) ? (int) $context['percent_left'] : null; - $window_is_estimate = isset( $context['window_is_estimated'] ) ? (bool) $context['window_is_estimated'] : null; - - $prompt_tokens = max( 0, $prompt_tokens ); - $completion_tokens = max( 0, $completion_tokens ); - $total_tokens = max( 0, $total_tokens ); - $used_tokens = max( 0, $used_tokens ); - $context_window = max( 0, $context_window ); - - if ( $context_window > 0 ) { - if ( null === $percent_used ) { - $percent_used = (int) round( min( 100, ( $used_tokens / $context_window ) * 100 ) ); - } - - $percent_used = max( 0, min( 100, $percent_used ) ); - $percent_left = null !== $percent_left - ? max( 0, min( 100, $percent_left ) ) - : max( 0, 100 - $percent_used ); - } else { - $percent_used = null; - $percent_left = null; - } - - if ( 0 === $prompt_tokens && 0 === $completion_tokens && 0 === $total_tokens && 0 === $used_tokens && 0 === $context_window ) { - return null; - } - - return [ - 'prompt_tokens' => $prompt_tokens, - 'completion_tokens' => $completion_tokens, - 'total_tokens' => $total_tokens, - 'used_tokens' => $used_tokens, - 'context_window_tokens' => $context_window > 0 ? $context_window : null, - 'percent_used' => $percent_used, - 'percent_left' => $percent_left, - 'window_is_estimated' => $window_is_estimate, - ]; + private function resolve_chat_slice_budget_ms( array $settings ): int { + $request_timeout = $this->settings_helper->get_request_timeout( $settings ); + $budget = (int) $request_timeout * 1000; + return max( 250, min( 15000, $budget ) ); } /** - * Resolve best-known context window for a provider/model pair. + * Resolve execution user for chat-originated runs. * - * @param string $provider Provider identifier. - * @param string $model Model identifier. + * @param array $settings Plugin settings. + * @param int $requesting_user_id Requesting user id. */ - private function resolve_context_window_tokens( string $provider, string $model ): int { - $provider = clawpress_sanitize_provider( $provider ); - $model = strtolower( trim( $model ) ); - if ( '' === $provider || '' === $model || ! isset( self::MODEL_CONTEXT_WINDOWS[ $provider ] ) ) { - return 0; + private function resolve_execution_user_id( array $settings, int $requesting_user_id ): int { + $execution_user_id = $this->settings_helper->resolve_agent_user_id( $settings ); + if ( $execution_user_id > 0 ) { + return $execution_user_id; } - foreach ( self::MODEL_CONTEXT_WINDOWS[ $provider ] as $prefix => $window ) { - if ( 0 !== strpos( $model, strtolower( (string) $prefix ) ) ) { - continue; - } - - return max( 0, (int) $window ); - } - - return 0; + return $requesting_user_id > 0 ? $requesting_user_id : 0; } /** - * Normalize card payload for chat responses. - * - * @param array $card Raw card payload. - * @return array|null + * Build a worker id for chat-side synchronous claims. */ - private function normalize_card_payload( array $card ): ?array { - $type = isset( $card['type'] ) ? strtolower( sanitize_text_field( (string) $card['type'] ) ) : ''; - $type = (string) preg_replace( '/[^a-z0-9_\-]/', '', $type ); - if ( '' === $type ) { - return null; - } - - return [ - 'type' => $type, - 'data' => isset( $card['data'] ) && is_array( $card['data'] ) - ? $card['data'] - : [], - ]; + private function build_chat_worker_id(): string { + return sprintf( 'clawpress-chat-%s', substr( md5( uniqid( '', true ) ), 0, 12 ) ); } /** @@ -694,323 +465,6 @@ private function build_card_fallback_reply( array $card ): string { return __( 'Action required.', 'clawpress' ); } - /** - * Build one pending confirmation row for a destructive tool call. - * - * @param array $tool_result Tool execution result. - * @param string $tool_name Tool name. - * @param mixed $raw_args Raw tool-call args. - * @return array|null - */ - private function normalize_pending_confirmation_tool_call( array $tool_result, string $tool_name, $raw_args ): ?array { - if ( empty( $tool_result['requires_confirmation'] ) ) { - return null; - } - - $normalized_tool_name = strtolower( trim( $tool_name ) ); - if ( '' === $normalized_tool_name ) { - return null; - } - - return [ - 'tool_name' => $normalized_tool_name, - 'ability_name' => isset( $tool_result['ability'] ) ? (string) $tool_result['ability'] : '', - 'args' => $this->normalize_tool_args_for_prompt( $raw_args ), - ]; - } - - /** - * Build a user-confirmation card payload from a pending batch. - * - * @param array|null $confirmation_batch Pending confirmation batch. - * @return array|null - */ - private function build_tool_confirmation_card( ?array $confirmation_batch ): ?array { - if ( ! is_array( $confirmation_batch ) ) { - return null; - } - - $batch_id = isset( $confirmation_batch['batch_id'] ) ? strtolower( trim( (string) $confirmation_batch['batch_id'] ) ) : ''; - $expires_at = isset( $confirmation_batch['expires_at'] ) ? (int) $confirmation_batch['expires_at'] : 0; - $calls = isset( $confirmation_batch['calls'] ) && is_array( $confirmation_batch['calls'] ) - ? array_values( $confirmation_batch['calls'] ) - : []; - - if ( '' === $batch_id || [] === $calls ) { - return null; - } - - $tool_names = array_values( - array_unique( - array_filter( - array_map( - static fn( $call ): string => isset( $call['tool_name'] ) - ? strtolower( trim( (string) $call['tool_name'] ) ) - : '', - $calls - ) - ) - ) - ); - $total_calls = count( $calls ); - $expires_at = $expires_at > 0 ? $expires_at : time(); - $expires_label = function_exists( 'wp_date' ) - ? wp_date( 'Y-m-d H:i:s', $expires_at ) - : gmdate( 'Y-m-d H:i:s', $expires_at ); - - if ( 1 === $total_calls ) { - $message = sprintf( - /* translators: 1: tool name, 2: batch ID, 3: expiry time */ - __( 'Confirm batch `%2$s` to run `%1$s`. This batch expires at %3$s.', 'clawpress' ), - $tool_names[0] ?? 'tool', - $batch_id, - $expires_label - ); - } else { - $message = sprintf( - /* translators: 1: total destructive calls, 2: batch ID, 3: comma-separated tool names, 4: expiry time */ - __( 'This reply queued %1$d destructive tool calls in batch `%2$s` (%3$s). Use Confirm All to execute the entire batch. This batch expires at %4$s.', 'clawpress' ), - $total_calls, - $batch_id, - [] !== $tool_names ? implode( ', ', $tool_names ) : __( 'tools', 'clawpress' ), - $expires_label - ); - } - - return [ - 'type' => 'user_confirmation', - 'data' => [ - 'title' => __( 'User Confirmation Required', 'clawpress' ), - 'subtitle' => __( 'Destructive batch pending', 'clawpress' ), - 'message' => $message, - 'actions' => [ - [ - 'id' => 'confirm-batch-' . md5( $batch_id ), - 'label' => $total_calls > 1 - ? __( 'Confirm All', 'clawpress' ) - : __( 'Confirm Action', 'clawpress' ), - 'type' => 'send_prompt', - 'prompt' => '/confirm --batch=' . $batch_id, - ], - [ - 'id' => 'decline-batch-' . md5( $batch_id ), - 'label' => __( 'Decline', 'clawpress' ), - 'type' => 'send_prompt', - 'prompt' => '/decline --batch=' . $batch_id, - ], - ], - ], - ]; - } - - /** - * Normalize tool-call args for prompt interpolation. - * - * @param mixed $raw_args Raw tool-call args payload. - * @return array - */ - private function normalize_tool_args_for_prompt( $raw_args ): array { - if ( is_array( $raw_args ) ) { - return $raw_args; - } - - if ( is_object( $raw_args ) ) { - return (array) $raw_args; - } - - if ( ! is_string( $raw_args ) || '' === trim( $raw_args ) ) { - return []; - } - - $decoded = json_decode( $raw_args, true ); - return is_array( $decoded ) ? $decoded : []; - } - - /** - * Normalize history message list. - * - * @param array $context Model context payload. - * @return array - */ - private function normalize_history_messages( array $context ): array { - $history_messages = []; - if ( ! isset( $context['history_messages'] ) || ! is_array( $context['history_messages'] ) ) { - return $history_messages; - } - - foreach ( $context['history_messages'] as $history_message ) { - if ( $history_message instanceof Message ) { - $history_messages[] = $history_message; - } - } - - return $history_messages; - } - - /** - * Normalize function declarations from context payload. - * - * @param array $context Model context payload. - * @return array - */ - private function normalize_tool_declarations( array $context ): array { - $declarations = []; - if ( ! isset( $context['tool_declarations'] ) || ! is_array( $context['tool_declarations'] ) ) { - return $declarations; - } - - foreach ( $context['tool_declarations'] as $declaration ) { - if ( $declaration instanceof FunctionDeclaration ) { - $declarations[] = $declaration; - } - } - - return $declarations; - } - - /** - * Build request options object. - * - * @param int $request_timeout Request timeout in seconds. - */ - private function build_request_options( int $request_timeout ): RequestOptions { - $request_options = new RequestOptions(); - $request_options->setTimeout( (float) max( 1, $request_timeout ) ); - return $request_options; - } - - /** - * Normalize generation settings payload. - * - * @param array $generation_settings Raw generation settings. - * @return array{temperature:float,top_p:float,max_output_tokens:int,frequency_penalty:float,presence_penalty:float} - */ - private function normalize_generation_settings( array $generation_settings ): array { - return [ - 'temperature' => clawpress_sanitize_temperature( $generation_settings['temperature'] ?? 0.2 ), - 'top_p' => clawpress_sanitize_top_p( $generation_settings['top_p'] ?? 0.9 ), - 'max_output_tokens' => clawpress_sanitize_max_output_tokens( $generation_settings['max_output_tokens'] ?? 1200 ), - 'frequency_penalty' => clawpress_sanitize_frequency_penalty( $generation_settings['frequency_penalty'] ?? 0.2 ), - 'presence_penalty' => clawpress_sanitize_presence_penalty( $generation_settings['presence_penalty'] ?? 0.0 ), - ]; - } - - /** - * Apply generation settings to prompt builder. - * - * Gracefully ignores unsupported parameters (or provider-specific rejections) - * by catching option-level exceptions and continuing with the remaining options. - * - * @param PromptBuilder $builder Prompt builder. - * @param array $generation_settings Generation settings. - * @param string $provider Provider identifier. - * @param string $model Model identifier. - */ - private function apply_generation_settings_to_prompt_builder( PromptBuilder $builder, array $generation_settings, string $provider, string $model ): PromptBuilder { - $option_setters = [ - static fn ( PromptBuilder $current ): PromptBuilder => $current->usingTemperature( (float) $generation_settings['temperature'] ), - static fn ( PromptBuilder $current ): PromptBuilder => $current->usingTopP( (float) $generation_settings['top_p'] ), - fn ( PromptBuilder $current ): PromptBuilder => $this->apply_max_output_tokens_to_prompt_builder( - $current, - (int) $generation_settings['max_output_tokens'], - $provider, - $model - ), - static fn ( PromptBuilder $current ): PromptBuilder => $current->usingFrequencyPenalty( (float) $generation_settings['frequency_penalty'] ), - static fn ( PromptBuilder $current ): PromptBuilder => $current->usingPresencePenalty( (float) $generation_settings['presence_penalty'] ), - ]; - - foreach ( $option_setters as $setter ) { - try { - $builder = $setter( $builder ); - } catch ( Throwable $throwable ) { - unset( $throwable ); - continue; - } - } - - return $builder; - } - - /** - * Apply max output token setting to prompt builder. - * - * Uses `max_completion_tokens` for OpenAI model families that reject - * legacy `max_tokens`. - * - * @param PromptBuilder $builder Prompt builder instance. - * @param int $max_output_tokens Max output tokens. - * @param string $provider Provider identifier. - * @param string $model Model identifier. - */ - private function apply_max_output_tokens_to_prompt_builder( PromptBuilder $builder, int $max_output_tokens, string $provider, string $model ): PromptBuilder { - if ( $this->provider_helper->should_use_max_completion_tokens( $provider, $model ) ) { - $model_config = ModelConfig::fromArray( - [ - ModelConfig::KEY_CUSTOM_OPTIONS => [ - 'max_completion_tokens' => $max_output_tokens, - ], - ] - ); - - return $builder->usingModelConfig( $model_config ); - } - - return $builder->usingMaxTokens( $max_output_tokens ); - } - - /** - * Extract function calls from an assistant/model message. - * - * @param Message $message Model message. - * @return array - */ - private function extract_function_calls( Message $message ): array { - $function_calls = []; - - foreach ( $message->getParts() as $part ) { - $function_call = $part->getFunctionCall(); - if ( $function_call instanceof FunctionCall ) { - $function_calls[] = $function_call; - } - } - - return $function_calls; - } - - /** - * Extract text content from an assistant/model message. - * - * @param Message $message Model message. - */ - private function extract_text_from_message( Message $message ): string { - $chunks = []; - - foreach ( $message->getParts() as $part ) { - $text = $part->getText(); - if ( null === $text || '' === trim( $text ) ) { - continue; - } - - $chunks[] = trim( $text ); - } - - return trim( implode( "\n", $chunks ) ); - } - - /** - * Resolve provider + model with default runtime behavior. - * - * @param array $settings Current settings. - * @return array{provider:string,model:string} - */ - private function resolve_provider_and_model( array $settings ): array { - return [ - 'provider' => $this->provider_helper->resolve_provider_with_fallback( $settings ), - 'model' => $this->provider_helper->resolve_model( $settings ), - ]; - } - /** * Build deterministic offline fallback response. * @@ -1070,37 +524,35 @@ private function get_online_suggestions( string $reply, string $provider, string } /** - * Build error payload for model/transport failures. + * Build error payload from runtime error data. * - * @param Throwable $throwable Thrown exception. - * @param string $provider Provider identifier. - * @param string $model Model identifier. + * @param array $error Runtime error payload. + * @param string $provider Provider identifier. + * @param string $model Model identifier. * @return array */ - private function build_error_reply_payload( Throwable $throwable, string $provider, string $model ): array { - $error_message = trim( sanitize_text_field( $throwable->getMessage() ) ); + private function build_runtime_error_reply_payload( array $error, string $provider, string $model ): array { + $error_message = isset( $error['message'] ) ? trim( sanitize_text_field( (string) $error['message'] ) ) : ''; if ( '' === $error_message ) { $error_message = __( 'Unknown provider error.', 'clawpress' ); } - $error_type = $this->classify_error_type( $throwable, $error_message ); - $reply = sprintf( - /* translators: %s: provider/transport error message */ - __( 'AI request failed: %s', 'clawpress' ), - $error_message - ); - - $card_subtitle = 'timeout' === $error_type - ? __( 'Request timed out', 'clawpress' ) - : __( 'Provider error', 'clawpress' ); + $error_type = isset( $error['type'] ) ? strtolower( trim( (string) $error['type'] ) ) : 'provider'; + if ( ! in_array( $error_type, [ 'timeout', 'provider' ], true ) ) { + $error_type = 'provider'; + } - $error_code = $throwable->getCode(); + $error_code = $error['code'] ?? 0; if ( ! is_int( $error_code ) && ! is_string( $error_code ) ) { $error_code = 0; } return [ - 'reply' => $reply, + 'reply' => sprintf( + /* translators: %s: provider/transport error message */ + __( 'AI request failed: %s', 'clawpress' ), + $error_message + ), 'mode' => 'error', 'provider' => '' !== $provider ? $provider : null, 'model' => '' !== $model ? $model : null, @@ -1115,7 +567,9 @@ private function build_error_reply_payload( Throwable $throwable, string $provid 'type' => 'error', 'data' => [ 'title' => __( 'Request Error', 'clawpress' ), - 'subtitle' => $card_subtitle, + 'subtitle' => 'timeout' === $error_type + ? __( 'Request timed out', 'clawpress' ) + : __( 'Provider error', 'clawpress' ), 'message' => $error_message, ], ], @@ -1123,27 +577,51 @@ private function build_error_reply_payload( Throwable $throwable, string $provid } /** - * Classify known provider error patterns. + * Build error payload for model/transport failures. * * @param Throwable $throwable Thrown exception. - * @param string $error_message Sanitized error message. + * @param string $provider Provider identifier. + * @param string $model Model identifier. + * @return array */ - private function classify_error_type( Throwable $throwable, string $error_message ): string { - $message = strtolower( $error_message . ' ' . $throwable->getMessage() ); - $patterns = [ - 'timed out', - 'timeout', - 'curl error 28', - 'deadline exceeded', - 'operation timed out', - ]; + private function build_error_reply_payload( Throwable $throwable, string $provider, string $model ): array { + $error_message = trim( sanitize_text_field( $throwable->getMessage() ) ); + if ( '' === $error_message ) { + $error_message = __( 'Unknown provider error.', 'clawpress' ); + } - foreach ( $patterns as $pattern ) { - if ( false !== strpos( $message, $pattern ) ) { - return 'timeout'; - } + $error_type = $this->agent_loop_helper->classify_provider_error_type( $throwable, $error_message ); + $error_code = $throwable->getCode(); + if ( ! is_int( $error_code ) && ! is_string( $error_code ) ) { + $error_code = 0; } - return 'provider'; + return [ + 'reply' => sprintf( + /* translators: %s: provider/transport error message */ + __( 'AI request failed: %s', 'clawpress' ), + $error_message + ), + 'mode' => 'error', + 'provider' => '' !== $provider ? $provider : null, + 'model' => '' !== $model ? $model : null, + 'suggestions' => $this->get_default_offline_suggestions(), + 'error' => [ + 'type' => $error_type, + 'message' => $error_message, + 'code' => $error_code, + 'retryable' => 'timeout' === $error_type, + ], + 'card' => [ + 'type' => 'error', + 'data' => [ + 'title' => __( 'Request Error', 'clawpress' ), + 'subtitle' => 'timeout' === $error_type + ? __( 'Request timed out', 'clawpress' ) + : __( 'Provider error', 'clawpress' ), + 'message' => $error_message, + ], + ], + ]; } } diff --git a/includes/helpers/class-context-helper.php b/includes/helpers/class-context-helper.php index e782a70..4397d19 100644 --- a/includes/helpers/class-context-helper.php +++ b/includes/helpers/class-context-helper.php @@ -262,7 +262,10 @@ public function build_model_context( string $current_message, ?int $user_id = nu } $normalized_history[] = $normalized_item; + } + $normalized_history = $this->trim_leading_non_user_history_items( $normalized_history ); + foreach ( $normalized_history as $normalized_item ) { $model_history_message = $this->convert_prompt_message_to_model_message( $normalized_item ); if ( $model_history_message instanceof Message ) { $model_history_messages[] = $model_history_message; @@ -299,6 +302,22 @@ public function build_model_context( string $current_message, ?int $user_id = nu ]; } + /** + * Ensure history starts with a user message to satisfy provider constraints. + * + * @param array $history Normalized history messages. + * @return array + */ + private function trim_leading_non_user_history_items( array $history ): array { + foreach ( $history as $index => $history_item ) { + if ( 'user' === $history_item['role'] ) { + return array_slice( $history, $index ); + } + } + + return []; + } + /** * Add a tool result message to a message list. * diff --git a/includes/helpers/class-policy-helper.php b/includes/helpers/class-policy-helper.php index 21d42aa..3607546 100644 --- a/includes/helpers/class-policy-helper.php +++ b/includes/helpers/class-policy-helper.php @@ -46,7 +46,12 @@ final class Policy_Helper { * @var array> */ private const TRIGGER_POLICY_OVERRIDES = [ - 'chat' => [], + 'chat' => [ + // Extended during runtime-loop testing to reduce premature wall-time exits. + 'max_wall_time_seconds' => 1200, + 'max_tool_rounds' => 6, + 'max_tool_calls_per_round' => 8, + ], 'heartbeat' => [ 'allow_destructive_tools' => false, 'allow_file_delete' => false, diff --git a/includes/helpers/class-provider-helper.php b/includes/helpers/class-provider-helper.php index b2038d1..5a3b6da 100644 --- a/includes/helpers/class-provider-helper.php +++ b/includes/helpers/class-provider-helper.php @@ -152,6 +152,19 @@ public function resolve_model( array $settings ): string { return trim( $settings['model'] ); } + /** + * Resolve provider and model with fallback behavior. + * + * @param array $settings Settings array. + * @return array{provider:string,model:string} + */ + public function resolve_provider_and_model( array $settings ): array { + return [ + 'provider' => $this->resolve_provider_with_fallback( $settings ), + 'model' => $this->resolve_model( $settings ), + ]; + } + /** * Whether a provider/model combination should use `max_completion_tokens`. * diff --git a/includes/rest/class-agent-run-controller.php b/includes/rest/class-agent-run-controller.php new file mode 100644 index 0000000..25edf56 --- /dev/null +++ b/includes/rest/class-agent-run-controller.php @@ -0,0 +1,494 @@ +run_helper = Agent_Run_Helper::get_instance(); + $this->session_helper = Agent_Session_Helper::get_instance(); + $this->event_helper = Agent_Event_Helper::get_instance(); + $this->settings_helper = Settings_Helper::get_instance(); + } + + /** + * Register routes. + */ + public function register_routes(): void { + register_rest_route( + 'clawpress/v1', + '/agent/runs', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'create_run' ], + 'permission_callback' => 'clawpress_check_permissions', + 'args' => [ + 'session_id' => [ + 'required' => false, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + 'trigger' => [ + 'required' => false, + 'sanitize_callback' => 'sanitize_key', + ], + 'message' => [ + 'required' => false, + 'sanitize_callback' => 'clawpress_sanitize_multiline_text', + ], + 'transport_mode' => [ + 'required' => false, + 'sanitize_callback' => 'sanitize_key', + ], + 'max_attempts' => [ + 'required' => false, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + 'slice_budget_ms' => [ + 'required' => false, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + 'max_steps_per_slice' => [ + 'required' => false, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + 'idempotency_key' => [ + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ] + ); + + register_rest_route( + 'clawpress/v1', + '/agent/spawn', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'spawn_agent' ], + 'permission_callback' => 'clawpress_check_permissions', + 'args' => [ + 'message' => [ + 'required' => false, + 'sanitize_callback' => 'clawpress_sanitize_multiline_text', + ], + 'transport_mode' => [ + 'required' => false, + 'sanitize_callback' => 'sanitize_key', + ], + 'max_attempts' => [ + 'required' => false, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + 'slice_budget_ms' => [ + 'required' => false, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + 'max_steps_per_slice' => [ + 'required' => false, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + 'idempotency_key' => [ + 'required' => false, + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ] + ); + + register_rest_route( + 'clawpress/v1', + '/agent/runs/(?P\d+)/enqueue', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'enqueue_run' ], + 'permission_callback' => 'clawpress_check_permissions', + 'args' => [ + 'run_id' => [ + 'required' => true, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + ], + ] + ); + + register_rest_route( + 'clawpress/v1', + '/agent/runs/(?P\d+)', + [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_run' ], + 'permission_callback' => 'clawpress_check_permissions', + 'args' => [ + 'run_id' => [ + 'required' => true, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + ], + ] + ); + + register_rest_route( + 'clawpress/v1', + '/agent/runs/(?P\d+)/events', + [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_run_events' ], + 'permission_callback' => 'clawpress_check_permissions', + 'args' => [ + 'run_id' => [ + 'required' => true, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + 'after' => [ + 'required' => false, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + 'limit' => [ + 'required' => false, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'clawpress_validate_int', + ], + ], + ] + ); + } + + /** + * Create an agent run. + * + * @param \WP_REST_Request $request Request object. + */ + public function create_run( \WP_REST_Request $request ): \WP_REST_Response { + $trigger = sanitize_key( (string) $request->get_param( 'trigger' ) ); + if ( '' === $trigger ) { + $trigger = 'chat'; + } + + $requesting_user_id = function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0; + $session_id = (int) $request->get_param( 'session_id' ); + if ( $session_id > 0 ) { + $session = $this->session_helper->get_session( $session_id ); + if ( [] === $session ) { + return new \WP_REST_Response( + [ + 'error' => __( 'Session not found.', 'clawpress' ), + ], + 404 + ); + } + } else { + $session_id = $this->session_helper->create_session( + [ + 'trigger_type' => $trigger, + 'requesting_user_id' => $requesting_user_id, + 'execution_user_id' => $this->resolve_execution_user_id( $requesting_user_id ), + 'policy_profile' => 'default', + ] + ); + } + + if ( $session_id <= 0 ) { + return new \WP_REST_Response( + [ + 'error' => __( 'Unable to create session for run.', 'clawpress' ), + ], + 500 + ); + } + + $idempotency_key = trim( (string) $request->get_param( 'idempotency_key' ) ); + if ( '' !== $idempotency_key ) { + $existing_run = $this->run_helper->get_run_by_idempotency_key( $session_id, $idempotency_key ); + if ( [] !== $existing_run && isset( $existing_run['id'] ) ) { + return new \WP_REST_Response( + [ + 'run_id' => (int) $existing_run['id'], + 'session_id' => $session_id, + 'status' => isset( $existing_run['status'] ) ? (string) $existing_run['status'] : 'queued', + 'deduplicated' => true, + ], + 200 + ); + } + } + + $run_id = $this->run_helper->create_run( + $session_id, + [ + 'trigger_type' => $trigger, + 'transport_mode' => $this->normalize_transport_mode( (string) $request->get_param( 'transport_mode' ) ), + 'max_attempts' => max( 1, (int) $request->get_param( 'max_attempts' ) > 0 ? (int) $request->get_param( 'max_attempts' ) : 5 ), + 'idempotency_key' => '' !== $idempotency_key ? $idempotency_key : null, + 'meta' => [ + 'message' => (string) $request->get_param( 'message' ), + 'slice_budget_ms' => max( 1, (int) $request->get_param( 'slice_budget_ms' ) > 0 ? (int) $request->get_param( 'slice_budget_ms' ) : 1500 ), + 'max_steps_per_slice' => max( 1, (int) $request->get_param( 'max_steps_per_slice' ) > 0 ? (int) $request->get_param( 'max_steps_per_slice' ) : 1 ), + ], + ] + ); + + if ( $run_id <= 0 ) { + return new \WP_REST_Response( + [ + 'error' => __( 'Unable to create run.', 'clawpress' ), + ], + 500 + ); + } + + Agent_Runner::enqueue_run_slice_action( $run_id ); + + return new \WP_REST_Response( + [ + 'run_id' => $run_id, + 'session_id' => $session_id, + 'status' => 'queued', + ], + 201 + ); + } + + /** + * Spawn a background agent session + first run. + * + * @param \WP_REST_Request $request Request object. + */ + public function spawn_agent( \WP_REST_Request $request ): \WP_REST_Response { + $requesting_user_id = function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0; + $session_id = $this->session_helper->create_session( + [ + 'trigger_type' => 'spawned_agent', + 'requesting_user_id' => $requesting_user_id, + 'execution_user_id' => $this->resolve_execution_user_id( $requesting_user_id ), + 'policy_profile' => 'default', + ] + ); + + if ( $session_id <= 0 ) { + return new \WP_REST_Response( + [ + 'error' => __( 'Unable to create session for spawned agent.', 'clawpress' ), + ], + 500 + ); + } + + $idempotency_key = trim( (string) $request->get_param( 'idempotency_key' ) ); + $run_id = $this->run_helper->create_run( + $session_id, + [ + 'trigger_type' => 'spawned_agent', + 'transport_mode' => $this->normalize_transport_mode( (string) $request->get_param( 'transport_mode' ) ), + 'max_attempts' => max( 1, (int) $request->get_param( 'max_attempts' ) > 0 ? (int) $request->get_param( 'max_attempts' ) : 5 ), + 'idempotency_key' => '' !== $idempotency_key ? $idempotency_key : null, + 'meta' => [ + 'message' => (string) $request->get_param( 'message' ), + 'slice_budget_ms' => max( 1, (int) $request->get_param( 'slice_budget_ms' ) > 0 ? (int) $request->get_param( 'slice_budget_ms' ) : 1500 ), + 'max_steps_per_slice' => max( 1, (int) $request->get_param( 'max_steps_per_slice' ) > 0 ? (int) $request->get_param( 'max_steps_per_slice' ) : 1 ), + ], + ] + ); + + if ( $run_id <= 0 ) { + return new \WP_REST_Response( + [ + 'error' => __( 'Unable to create run for spawned agent.', 'clawpress' ), + ], + 500 + ); + } + + Agent_Runner::enqueue_run_slice_action( $run_id ); + + return new \WP_REST_Response( + [ + 'session_id' => $session_id, + 'run_id' => $run_id, + 'trigger' => 'spawned_agent', + 'status' => 'queued', + ], + 201 + ); + } + + /** + * Re-enqueue an agent run. + * + * @param \WP_REST_Request $request Request object. + */ + public function enqueue_run( \WP_REST_Request $request ): \WP_REST_Response { + $run_id = (int) $request->get_param( 'run_id' ); + $run = $this->run_helper->get_run( $run_id ); + if ( [] === $run ) { + return new \WP_REST_Response( + [ + 'error' => __( 'Run not found.', 'clawpress' ), + ], + 404 + ); + } + + $current_status = isset( $run['status'] ) ? (string) $run['status'] : 'queued'; + if ( ! $this->run_helper->is_enqueueable_status( $current_status ) ) { + return new \WP_REST_Response( + [ + 'error' => __( 'Run is not in a retryable state.', 'clawpress' ), + ], + 409 + ); + } + + if ( ! $this->run_helper->enqueue_run( $run_id ) ) { + return new \WP_REST_Response( + [ + 'error' => __( 'Unable to enqueue run.', 'clawpress' ), + ], + 500 + ); + } + + Agent_Runner::enqueue_run_slice_action( $run_id ); + + return new \WP_REST_Response( + [ + 'run_id' => $run_id, + 'status' => 'queued', + ], + 200 + ); + } + + /** + * Get one run status summary. + * + * @param \WP_REST_Request $request Request object. + */ + public function get_run( \WP_REST_Request $request ): \WP_REST_Response { + $run_id = (int) $request->get_param( 'run_id' ); + $run = $this->run_helper->get_run_status_summary( $run_id ); + if ( [] === $run ) { + return new \WP_REST_Response( + [ + 'error' => __( 'Run not found.', 'clawpress' ), + ], + 404 + ); + } + + return new \WP_REST_Response( $run, 200 ); + } + + /** + * Get run events after event cursor. + * + * @param \WP_REST_Request $request Request object. + */ + public function get_run_events( \WP_REST_Request $request ): \WP_REST_Response { + $run_id = (int) $request->get_param( 'run_id' ); + $after = max( 0, (int) $request->get_param( 'after' ) ); + $limit = max( 1, min( 200, (int) $request->get_param( 'limit' ) ) ); + + $events = $this->event_helper->get_run_events( $run_id, $after, $limit ); + $next = $after; + if ( [] !== $events ) { + $next = (int) $events[ count( $events ) - 1 ]['event_id']; + } + + return new \WP_REST_Response( + [ + 'run_id' => $run_id, + 'after' => $after, + 'next_cursor' => $next, + 'events' => $events, + ], + 200 + ); + } + + /** + * Normalize transport mode. + * + * @param string $transport_mode Raw transport mode. + */ + private function normalize_transport_mode( string $transport_mode ): string { + $transport_mode = strtolower( trim( $transport_mode ) ); + if ( ! in_array( $transport_mode, [ 'polling', 'streaming' ], true ) ) { + return 'polling'; + } + + return $transport_mode; + } + + /** + * Resolve execution user for run/spawn session creation. + * + * @param int $requesting_user_id Requesting user id. + */ + private function resolve_execution_user_id( int $requesting_user_id ): int { + $execution_user_id = $this->settings_helper->resolve_agent_user_id(); + if ( $execution_user_id > 0 ) { + return $execution_user_id; + } + + return $requesting_user_id > 0 ? $requesting_user_id : 0; + } +} diff --git a/includes/rest/class-chat-controller.php b/includes/rest/class-chat-controller.php index c2f62a2..3afd808 100644 --- a/includes/rest/class-chat-controller.php +++ b/includes/rest/class-chat-controller.php @@ -176,29 +176,33 @@ static function ( $tool_call ): array { 'message' => $message, 'reply' => $reply, 'meta' => [ - 'mode' => isset( $reply_payload['mode'] ) ? (string) $reply_payload['mode'] : 'offline', - 'provider' => isset( $reply_payload['provider'] ) && '' !== (string) $reply_payload['provider'] + 'mode' => isset( $reply_payload['mode'] ) ? (string) $reply_payload['mode'] : 'offline', + 'provider' => isset( $reply_payload['provider'] ) && '' !== (string) $reply_payload['provider'] ? (string) $reply_payload['provider'] : null, - 'model' => isset( $reply_payload['model'] ) && '' !== (string) $reply_payload['model'] + 'model' => isset( $reply_payload['model'] ) && '' !== (string) $reply_payload['model'] ? (string) $reply_payload['model'] : null, - 'suggestions' => isset( $reply_payload['suggestions'] ) && is_array( $reply_payload['suggestions'] ) + 'suggestions' => isset( $reply_payload['suggestions'] ) && is_array( $reply_payload['suggestions'] ) ? array_values( $reply_payload['suggestions'] ) : null, - 'card' => isset( $reply_payload['card'] ) && is_array( $reply_payload['card'] ) + 'card' => isset( $reply_payload['card'] ) && is_array( $reply_payload['card'] ) ? $reply_payload['card'] : null, - 'command' => isset( $reply_payload['command'] ) && is_array( $reply_payload['command'] ) + 'command' => isset( $reply_payload['command'] ) && is_array( $reply_payload['command'] ) ? $reply_payload['command'] : null, - 'error' => isset( $reply_payload['error'] ) && is_array( $reply_payload['error'] ) + 'error' => isset( $reply_payload['error'] ) && is_array( $reply_payload['error'] ) ? $reply_payload['error'] : null, - 'context' => isset( $reply_payload['context'] ) && is_array( $reply_payload['context'] ) + 'context' => isset( $reply_payload['context'] ) && is_array( $reply_payload['context'] ) ? $reply_payload['context'] : null, - 'tool_calls' => $tool_calls_meta, + 'tool_calls' => $tool_calls_meta, + 'run_id' => isset( $reply_payload['run_id'] ) ? (int) $reply_payload['run_id'] : null, + 'session_id' => isset( $reply_payload['session_id'] ) ? (int) $reply_payload['session_id'] : null, + 'events_cursor' => isset( $reply_payload['events_cursor'] ) ? (int) $reply_payload['events_cursor'] : null, + 'status' => isset( $reply_payload['status'] ) ? (string) $reply_payload['status'] : null, ], ], 200 diff --git a/includes/stores/class-action-log-store.php b/includes/stores/class-action-log-store.php index e658523..20b4b44 100644 --- a/includes/stores/class-action-log-store.php +++ b/includes/stores/class-action-log-store.php @@ -158,42 +158,33 @@ public function get_recent_logs( array $args = [] ): array { $limit = $limit > 0 ? min( $limit, 500 ) : 50; $offset = $offset >= 0 ? $offset : 0; - $where_clauses = []; - $where_values = []; - - if ( isset( $args['event_type'] ) && '' !== (string) $args['event_type'] ) { - $where_clauses[] = 'event_type = %s'; - $where_values[] = (string) $args['event_type']; - } - - if ( isset( $args['status'] ) && '' !== (string) $args['status'] ) { - $where_clauses[] = 'status = %s'; - $where_values[] = (string) $args['status']; - } - - if ( isset( $args['requesting_user_id'] ) && (int) $args['requesting_user_id'] > 0 ) { - $where_clauses[] = 'requesting_user_id = %d'; - $where_values[] = (int) $args['requesting_user_id']; - } - - if ( isset( $args['execution_user_id'] ) && (int) $args['execution_user_id'] > 0 ) { - $where_clauses[] = 'execution_user_id = %d'; - $where_values[] = (int) $args['execution_user_id']; - } - - $where_sql = [] !== $where_clauses ? 'WHERE ' . implode( ' AND ', $where_clauses ) : ''; - $where_values[] = $limit; - $where_values[] = $offset; - - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. - $query = "SELECT id, event_type, action_name, status, message, requesting_user_id, execution_user_id, context, created_at - FROM {$this->get_table_name()} - {$where_sql} - ORDER BY id DESC - LIMIT %d OFFSET %d"; + $event_type = isset( $args['event_type'] ) ? trim( (string) $args['event_type'] ) : ''; + $status = isset( $args['status'] ) ? trim( (string) $args['status'] ) : ''; + $requesting_user_id = isset( $args['requesting_user_id'] ) ? max( 0, (int) $args['requesting_user_id'] ) : 0; + $execution_user_id = isset( $args['execution_user_id'] ) ? max( 0, (int) $args['execution_user_id'] ) : 0; + + $prepared_query = $wpdb->prepare( + "SELECT id, event_type, action_name, status, message, requesting_user_id, execution_user_id, context, created_at + FROM %i + WHERE (%s = '' OR event_type = %s) + AND (%s = '' OR status = %s) + AND (%d = 0 OR requesting_user_id = %d) + AND (%d = 0 OR execution_user_id = %d) + ORDER BY id DESC + LIMIT %d OFFSET %d", + $this->get_table_name(), + $event_type, + $event_type, + $status, + $status, + $requesting_user_id, + $requesting_user_id, + $execution_user_id, + $execution_user_id, + $limit, + $offset + ); - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared via `$wpdb->prepare()` on this line. - $prepared_query = $wpdb->prepare( $query, $where_values ); if ( ! is_string( $prepared_query ) || '' === $prepared_query ) { return []; } diff --git a/includes/stores/class-agent-event-store.php b/includes/stores/class-agent-event-store.php index 79055fb..106eeea 100644 --- a/includes/stores/class-agent-event-store.php +++ b/includes/stores/class-agent-event-store.php @@ -146,36 +146,30 @@ public function get_events( array $args = [] ): array { $after = isset( $args['after_event_id'] ) ? (int) $args['after_event_id'] : 0; $after = $after > 0 ? $after : 0; - $where_clauses = [ 'id > %d' ]; - $where_values = [ $after ]; - - if ( isset( $args['run_id'] ) && (int) $args['run_id'] > 0 ) { - $where_clauses[] = 'run_id = %d'; - $where_values[] = (int) $args['run_id']; - } - - if ( isset( $args['session_id'] ) && (int) $args['session_id'] > 0 ) { - $where_clauses[] = 'session_id = %d'; - $where_values[] = (int) $args['session_id']; - } - - if ( isset( $args['event_type'] ) && '' !== (string) $args['event_type'] ) { - $where_clauses[] = 'event_type = %s'; - $where_values[] = (string) $args['event_type']; - } - - $where_values[] = $limit; - $where_sql = 'WHERE ' . implode( ' AND ', $where_clauses ); - - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. - $query = "SELECT id, run_id, session_id, event_type, payload_json, created_at_gmt - FROM {$this->get_table_name()} - {$where_sql} - ORDER BY id ASC - LIMIT %d"; + $run_id = isset( $args['run_id'] ) ? max( 0, (int) $args['run_id'] ) : 0; + $session_id = isset( $args['session_id'] ) ? max( 0, (int) $args['session_id'] ) : 0; + $event_type = isset( $args['event_type'] ) ? trim( (string) $args['event_type'] ) : ''; + + $prepared_query = $wpdb->prepare( + "SELECT id, run_id, session_id, event_type, payload_json, created_at_gmt + FROM %i + WHERE id > %d + AND (%d = 0 OR run_id = %d) + AND (%d = 0 OR session_id = %d) + AND (%s = '' OR event_type = %s) + ORDER BY id ASC + LIMIT %d", + $this->get_table_name(), + $after, + $run_id, + $run_id, + $session_id, + $session_id, + $event_type, + $event_type, + $limit + ); - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared via `$wpdb->prepare()` on this line. - $prepared_query = $wpdb->prepare( $query, $where_values ); if ( ! is_string( $prepared_query ) || '' === $prepared_query ) { return []; } diff --git a/includes/stores/class-agent-run-store.php b/includes/stores/class-agent-run-store.php index 36df87e..fa4da95 100644 --- a/includes/stores/class-agent-run-store.php +++ b/includes/stores/class-agent-run-store.php @@ -70,29 +70,38 @@ public function create_table(): bool { ? (string) $wpdb->get_charset_collate() : ''; - $sql = "CREATE TABLE {$this->get_table_name()} ( + $sql = "CREATE TABLE {$this->get_table_name()} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, session_id bigint(20) unsigned NOT NULL, run_uuid char(36) NOT NULL, + trigger_type varchar(32) NOT NULL DEFAULT 'chat', + transport_mode varchar(16) NOT NULL DEFAULT 'polling', status varchar(32) NOT NULL DEFAULT 'queued', claimed_by varchar(64) NULL, lock_token char(64) NULL, - lock_acquired_at_gmt datetime NULL, - lock_expires_at_gmt datetime NULL, - attempt int(11) NOT NULL DEFAULT 1, + lock_acquired_at_gmt datetime NULL, + lock_expires_at_gmt datetime NULL, + attempt int(11) NOT NULL DEFAULT 1, + retry_count int(11) NOT NULL DEFAULT 0, + max_attempts int(11) NOT NULL DEFAULT 5, + next_retry_at_gmt datetime NULL, started_at_gmt datetime NULL, finished_at_gmt datetime NULL, + resume_cursor_json longtext NULL, error_code varchar(128) NULL, error_message text NULL, meta_json longtext NULL, + idempotency_key varchar(128) NULL, created_at_gmt datetime NOT NULL, updated_at_gmt datetime NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY run_uuid (run_uuid), - KEY session_status (session_id, status), - KEY status_lock_expires_at_gmt (status, lock_expires_at_gmt), - KEY claimed_by (claimed_by) - ) {$charset_collate};"; + PRIMARY KEY (id), + UNIQUE KEY run_uuid (run_uuid), + UNIQUE KEY session_idempotency_key (session_id, idempotency_key), + KEY session_status (session_id, status), + KEY status_lock_expires_at_gmt (status, lock_expires_at_gmt), + KEY status_next_retry_at_gmt (status, next_retry_at_gmt), + KEY claimed_by (claimed_by) + ) {$charset_collate};"; if ( ! function_exists( 'dbDelta' ) ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; @@ -109,11 +118,9 @@ public function create_table(): bool { /** * Insert a queued run row. * - * @param int $session_id Session identifier. - * @param string $run_uuid Run UUID. - * @param string $created_at_gmt Created-at timestamp (UTC). + * @param array $data Run payload. */ - public function insert_run( int $session_id, string $run_uuid, string $created_at_gmt ): int { + public function insert_run( array $data ): int { global $wpdb; if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'insert' ) ) { @@ -124,14 +131,37 @@ public function insert_run( int $session_id, string $run_uuid, string $created_a $wpdb->insert( $this->get_table_name(), [ - 'session_id' => $session_id, - 'run_uuid' => $run_uuid, - 'status' => 'queued', - 'attempt' => 1, - 'created_at_gmt' => $created_at_gmt, - 'updated_at_gmt' => $created_at_gmt, + 'session_id' => isset( $data['session_id'] ) ? (int) $data['session_id'] : 0, + 'run_uuid' => isset( $data['run_uuid'] ) ? (string) $data['run_uuid'] : '', + 'trigger_type' => isset( $data['trigger_type'] ) ? (string) $data['trigger_type'] : 'chat', + 'transport_mode' => isset( $data['transport_mode'] ) ? (string) $data['transport_mode'] : 'polling', + 'status' => isset( $data['status'] ) ? (string) $data['status'] : 'queued', + 'attempt' => isset( $data['attempt'] ) ? (int) $data['attempt'] : 1, + 'retry_count' => isset( $data['retry_count'] ) ? max( 0, (int) $data['retry_count'] ) : 0, + 'max_attempts' => isset( $data['max_attempts'] ) ? (int) $data['max_attempts'] : 5, + 'next_retry_at_gmt' => $data['next_retry_at_gmt'] ?? null, + 'resume_cursor_json' => $data['resume_cursor_json'] ?? null, + 'meta_json' => $data['meta_json'] ?? null, + 'idempotency_key' => isset( $data['idempotency_key'] ) ? (string) $data['idempotency_key'] : null, + 'created_at_gmt' => isset( $data['created_at_gmt'] ) ? (string) $data['created_at_gmt'] : gmdate( 'Y-m-d H:i:s' ), + 'updated_at_gmt' => isset( $data['updated_at_gmt'] ) ? (string) $data['updated_at_gmt'] : gmdate( 'Y-m-d H:i:s' ), ], - [ '%d', '%s', '%s', '%d', '%s', '%s' ] + [ + '%d', + '%s', + '%s', + '%s', + '%s', + '%d', + '%d', + '%d', + '%s', + '%s', + '%s', + '%s', + '%s', + '%s', + ] ); return isset( $wpdb->insert_id ) ? (int) $wpdb->insert_id : 0; @@ -190,6 +220,45 @@ public function update_claim( ); } + /** + * Update in-progress run state with lock-token guard. + * + * @param int $run_id Run identifier. + * @param string $lock_token Lock token guard. + * @param array $data Update payload. + * @return int|false + */ + public function update_progress( int $run_id, string $lock_token, array $data ) { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) ) { + return false; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded repository update. + return $wpdb->update( + $this->get_table_name(), + [ + 'status' => isset( $data['status'] ) ? (string) $data['status'] : 'paused', + 'next_retry_at_gmt' => $data['next_retry_at_gmt'] ?? null, + 'resume_cursor_json' => $data['resume_cursor_json'] ?? null, + 'meta_json' => $data['meta_json'] ?? null, + 'retry_count' => isset( $data['retry_count'] ) ? max( 0, (int) $data['retry_count'] ) : 0, + 'lock_token' => null, + 'claimed_by' => null, + 'lock_acquired_at_gmt' => null, + 'lock_expires_at_gmt' => null, + 'updated_at_gmt' => isset( $data['updated_at_gmt'] ) ? (string) $data['updated_at_gmt'] : null, + ], + [ + 'id' => $run_id, + 'lock_token' => $lock_token, + ], + [ '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s' ], + [ '%d', '%s' ] + ); + } + /** * Complete a run with lock-token guard. * @@ -215,6 +284,8 @@ public function update_completion( int $run_id, string $lock_token, array $data 'lock_acquired_at_gmt' => null, 'lock_expires_at_gmt' => null, 'finished_at_gmt' => isset( $data['finished_at_gmt'] ) ? (string) $data['finished_at_gmt'] : null, + 'resume_cursor_json' => $data['resume_cursor_json'] ?? null, + 'next_retry_at_gmt' => $data['next_retry_at_gmt'] ?? null, 'error_code' => $data['error_code'] ?? null, 'error_message' => $data['error_message'] ?? null, 'meta_json' => $data['meta_json'] ?? null, @@ -224,11 +295,87 @@ public function update_completion( int $run_id, string $lock_token, array $data 'id' => $run_id, 'lock_token' => $lock_token, ], - [ '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ], + [ '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ], [ '%d', '%s' ] ); } + /** + * Re-enqueue run by ID. + * + * @param int $run_id Run identifier. + * @param string $current_status Expected current status. + * @param string $updated_at_gmt Update timestamp. + * @return int|false + */ + public function update_enqueue( int $run_id, string $current_status, string $updated_at_gmt ) { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) ) { + return false; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded repository update. + return $wpdb->update( + $this->get_table_name(), + [ + 'status' => 'queued', + 'next_retry_at_gmt' => null, + 'resume_cursor_json' => null, + 'updated_at_gmt' => $updated_at_gmt, + ], + [ + 'id' => $run_id, + 'status' => $current_status, + ], + [ '%s', '%s', '%s', '%s' ], + [ '%d', '%s' ] + ); + } + + /** + * Fetch runnable run rows ordered by age. + * + * @param int $limit Max rows. + * @param string $now_gmt Current timestamp. + * @return array> + */ + public function get_runnable_runs( int $limit, string $now_gmt ): array { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'get_results' ) ) { + return []; + } + + $limit = max( 1, min( 100, $limit ) ); + $query = $wpdb->prepare( + "SELECT * FROM %i + WHERE ( + status IN ('queued', 'paused') + AND (next_retry_at_gmt IS NULL OR next_retry_at_gmt <= %s) + ) + OR ( + status = 'running' + AND lock_expires_at_gmt IS NOT NULL + AND lock_expires_at_gmt <= %s + ) + ORDER BY created_at_gmt ASC + LIMIT %d", + $this->get_table_name(), + $now_gmt, + $now_gmt, + $limit + ); + + if ( ! is_string( $query ) || '' === $query ) { + return []; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded runnable-run lookup. + $rows = $wpdb->get_results( $query, 'ARRAY_A' ); + return is_array( $rows ) ? array_values( $rows ) : []; + } + /** * Fetch one run by id. * @@ -242,9 +389,7 @@ public function get_run( int $run_id ): array { return []; } - $table_name = $this->get_table_name(); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. - $query = $wpdb->prepare( "SELECT * FROM {$table_name} WHERE id = %d", $run_id ); + $query = $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $this->get_table_name(), $run_id ); if ( ! is_string( $query ) || '' === $query ) { return []; } @@ -254,6 +399,45 @@ public function get_run( int $run_id ): array { return is_array( $row ) ? $row : []; } + /** + * Fetch one run by session + idempotency key. + * + * @param int $session_id Session identifier. + * @param string $idempotency_key Idempotency key. + * @return array + */ + public function get_run_by_idempotency_key( int $session_id, string $idempotency_key ): array { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'get_row' ) ) { + return []; + } + + $idempotency_key = trim( $idempotency_key ); + if ( $session_id <= 0 || '' === $idempotency_key ) { + return []; + } + + $query = $wpdb->prepare( + "SELECT * FROM %i + WHERE session_id = %d + AND idempotency_key = %s + ORDER BY id DESC + LIMIT 1", + $this->get_table_name(), + $session_id, + $idempotency_key + ); + + if ( ! is_string( $query ) || '' === $query ) { + return []; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded lookup by session + idempotency key. + $row = $wpdb->get_row( $query, 'ARRAY_A' ); + return is_array( $row ) ? $row : []; + } + /** * Begin transaction. */ diff --git a/includes/stores/class-agent-session-store.php b/includes/stores/class-agent-session-store.php index 12384dd..f01a445 100644 --- a/includes/stores/class-agent-session-store.php +++ b/includes/stores/class-agent-session-store.php @@ -73,7 +73,7 @@ public function create_table(): bool { $sql = "CREATE TABLE {$this->get_table_name()} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, uuid char(36) NOT NULL, - status varchar(32) NOT NULL DEFAULT 'active', + status varchar(32) NOT NULL DEFAULT 'idle', trigger_type varchar(32) NOT NULL DEFAULT 'chat', requesting_user_id bigint(20) unsigned NULL, execution_user_id bigint(20) unsigned NULL, @@ -82,12 +82,17 @@ public function create_table(): bool { next_run_at_gmt datetime NULL, last_run_status varchar(32) NULL, consecutive_failures int(11) NOT NULL DEFAULT 0, + lease_owner varchar(64) NULL, + lease_token char(64) NULL, + lease_acquired_at_gmt datetime NULL, + lease_expires_at_gmt datetime NULL, created_at_gmt datetime NOT NULL, updated_at_gmt datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uuid (uuid), KEY status_next_run_at_gmt (status, next_run_at_gmt), - KEY trigger_type (trigger_type) + KEY trigger_type (trigger_type), + KEY lease_expires_at_gmt (lease_expires_at_gmt) ) {$charset_collate};"; if ( ! function_exists( 'dbDelta' ) ) { @@ -118,18 +123,22 @@ public function insert_session( array $data ): int { $wpdb->insert( $this->get_table_name(), [ - 'uuid' => isset( $data['uuid'] ) ? (string) $data['uuid'] : '', - 'status' => isset( $data['status'] ) ? (string) $data['status'] : 'active', - 'trigger_type' => isset( $data['trigger_type'] ) ? (string) $data['trigger_type'] : 'chat', - 'requesting_user_id' => $data['requesting_user_id'] ?? null, - 'execution_user_id' => $data['execution_user_id'] ?? null, - 'policy_profile' => $data['policy_profile'] ?? null, - 'last_run_at_gmt' => $data['last_run_at_gmt'] ?? null, - 'next_run_at_gmt' => $data['next_run_at_gmt'] ?? null, - 'last_run_status' => $data['last_run_status'] ?? null, - 'consecutive_failures' => isset( $data['consecutive_failures'] ) ? (int) $data['consecutive_failures'] : 0, - 'created_at_gmt' => isset( $data['created_at_gmt'] ) ? (string) $data['created_at_gmt'] : gmdate( 'Y-m-d H:i:s' ), - 'updated_at_gmt' => isset( $data['updated_at_gmt'] ) ? (string) $data['updated_at_gmt'] : gmdate( 'Y-m-d H:i:s' ), + 'uuid' => isset( $data['uuid'] ) ? (string) $data['uuid'] : '', + 'status' => isset( $data['status'] ) ? (string) $data['status'] : 'idle', + 'trigger_type' => isset( $data['trigger_type'] ) ? (string) $data['trigger_type'] : 'chat', + 'requesting_user_id' => $data['requesting_user_id'] ?? null, + 'execution_user_id' => $data['execution_user_id'] ?? null, + 'policy_profile' => $data['policy_profile'] ?? null, + 'last_run_at_gmt' => $data['last_run_at_gmt'] ?? null, + 'next_run_at_gmt' => $data['next_run_at_gmt'] ?? null, + 'last_run_status' => $data['last_run_status'] ?? null, + 'consecutive_failures' => isset( $data['consecutive_failures'] ) ? (int) $data['consecutive_failures'] : 0, + 'lease_owner' => $data['lease_owner'] ?? null, + 'lease_token' => $data['lease_token'] ?? null, + 'lease_acquired_at_gmt' => $data['lease_acquired_at_gmt'] ?? null, + 'lease_expires_at_gmt' => $data['lease_expires_at_gmt'] ?? null, + 'created_at_gmt' => isset( $data['created_at_gmt'] ) ? (string) $data['created_at_gmt'] : gmdate( 'Y-m-d H:i:s' ), + 'updated_at_gmt' => isset( $data['updated_at_gmt'] ) ? (string) $data['updated_at_gmt'] : gmdate( 'Y-m-d H:i:s' ), ], [ '%s', @@ -144,6 +153,10 @@ public function insert_session( array $data ): int { '%d', '%s', '%s', + '%s', + '%s', + '%s', + '%s', ] ); @@ -154,6 +167,109 @@ public function insert_session( array $data ): int { return (int) $wpdb->insert_id; } + /** + * Fetch one session by ID. + * + * @param int $session_id Session ID. + * @return array + */ + public function get_session( int $session_id ): array { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'get_row' ) ) { + return []; + } + + $query = $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $this->get_table_name(), $session_id ); + if ( ! is_string( $query ) || '' === $query ) { + return []; + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded primary-key lookup. + $row = $wpdb->get_row( $query, 'ARRAY_A' ); + return is_array( $row ) ? $row : []; + } + + /** + * Compare-and-swap session lease claim update. + * + * @param int $session_id Session identifier. + * @param string $current_status Expected status. + * @param string|null $current_lease_expires_at_gmt Expected lease expiry when reclaiming. + * @param array $data Update data. + * @param bool $is_stale Whether this claim is a stale reclaim. + * @return int|false + */ + public function update_claim( int $session_id, string $current_status, ?string $current_lease_expires_at_gmt, array $data, bool $is_stale ) { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) ) { + return false; + } + + $where = [ + 'id' => $session_id, + 'status' => $current_status, + ]; + $where_format = [ '%d', '%s' ]; + + if ( $is_stale ) { + $where['lease_expires_at_gmt'] = $current_lease_expires_at_gmt; + $where_format[] = '%s'; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded compare-and-swap style update. + return $wpdb->update( + $this->get_table_name(), + [ + 'status' => isset( $data['status'] ) ? (string) $data['status'] : 'running', + 'lease_owner' => isset( $data['lease_owner'] ) ? (string) $data['lease_owner'] : null, + 'lease_token' => isset( $data['lease_token'] ) ? (string) $data['lease_token'] : null, + 'lease_acquired_at_gmt' => isset( $data['lease_acquired_at_gmt'] ) ? (string) $data['lease_acquired_at_gmt'] : null, + 'lease_expires_at_gmt' => isset( $data['lease_expires_at_gmt'] ) ? (string) $data['lease_expires_at_gmt'] : null, + 'updated_at_gmt' => isset( $data['updated_at_gmt'] ) ? (string) $data['updated_at_gmt'] : null, + ], + $where, + [ '%s', '%s', '%s', '%s', '%s', '%s' ], + $where_format + ); + } + + /** + * Release a session lease by token. + * + * @param int $session_id Session identifier. + * @param string $lease_token Lease token guard. + * @param array $data Update data. + * @return int|false + */ + public function update_release( int $session_id, string $lease_token, array $data ) { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'update' ) ) { + return false; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded repository update. + return $wpdb->update( + $this->get_table_name(), + [ + 'status' => isset( $data['status'] ) ? (string) $data['status'] : 'idle', + 'lease_owner' => null, + 'lease_token' => null, + 'lease_acquired_at_gmt' => null, + 'lease_expires_at_gmt' => null, + 'updated_at_gmt' => isset( $data['updated_at_gmt'] ) ? (string) $data['updated_at_gmt'] : null, + ], + [ + 'id' => $session_id, + 'lease_token' => $lease_token, + ], + [ '%s', '%s', '%s', '%s', '%s', '%s' ], + [ '%d', '%s' ] + ); + } + /** * Update parent session state after run completion. * @@ -169,23 +285,24 @@ public function update_run_completion( int $session_id, string $run_status, ?str return false; } - $table_name = $this->get_table_name(); - $query = $wpdb->prepare( - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is fixed plugin-owned identifier. - "UPDATE {$table_name} + $query = $wpdb->prepare( + "UPDATE %i SET last_run_at_gmt = %s, last_run_status = %s, consecutive_failures = CASE - WHEN %s = 'success' THEN 0 + WHEN %s IN ('success', 'done', 'requires_confirmation') THEN 0 + WHEN %s = 'paused' THEN consecutive_failures ELSE consecutive_failures + 1 END, next_run_at_gmt = %s, updated_at_gmt = %s WHERE id = %d", + $this->get_table_name(), $updated_at_gmt, $run_status, $run_status, + $run_status, $next_run_at_gmt, $updated_at_gmt, $session_id diff --git a/includes/transports/class-null-transport.php b/includes/transports/class-null-transport.php new file mode 100644 index 0000000..008e1c7 --- /dev/null +++ b/includes/transports/class-null-transport.php @@ -0,0 +1,31 @@ + $event Event payload. + */ + public function emit( array $event ): void { + unset( $event ); + } + + /** + * Close transport. + */ + public function close(): void {} +} diff --git a/includes/transports/class-polling-transport.php b/includes/transports/class-polling-transport.php new file mode 100644 index 0000000..e20efcc --- /dev/null +++ b/includes/transports/class-polling-transport.php @@ -0,0 +1,96 @@ +event_helper = Agent_Event_Helper::get_instance(); + $this->run_id = $run_id; + $this->session_id = $session_id; + } + + /** + * Emit one event. + * + * @param array $event Event payload. + */ + public function emit( array $event ): void { + $event_type = isset( $event['type'] ) ? (string) $event['type'] : 'agent.event'; + $payload = isset( $event['payload'] ) && is_array( $event['payload'] ) + ? $event['payload'] + : []; + + $event_id = $this->event_helper->emit( + $event_type, + [ + 'run_id' => $this->run_id, + 'session_id' => $this->session_id, + 'payload' => $payload, + ] + ); + + if ( $event_id > 0 ) { + $this->last_event_id = $event_id; + } + } + + /** + * Close transport. + */ + public function close(): void {} + + /** + * Get last emitted event id. + */ + public function get_last_event_id(): int { + return $this->last_event_id; + } +} diff --git a/includes/transports/interface-agent-transport.php b/includes/transports/interface-agent-transport.php new file mode 100644 index 0000000..ae20aab --- /dev/null +++ b/includes/transports/interface-agent-transport.php @@ -0,0 +1,29 @@ + $event Event payload. + */ + public function emit( array $event ): void; + + /** + * Close transport resources. + */ + public function close(): void; +} diff --git a/languages/clawpress.pot b/languages/clawpress.pot index 2a09b99..0585a7b 100644 --- a/languages/clawpress.pot +++ b/languages/clawpress.pot @@ -9,7 +9,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-02-22T17:22:09+00:00\n" +"POT-Creation-Date: 2026-02-24T15:52:13+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: clawpress\n" @@ -218,6 +218,37 @@ msgstr "" msgid "Agent Memories" msgstr "" +#: includes/class-agent-runner.php:222 +msgid "Run session could not be loaded." +msgstr "" + +#: includes/class-agent-runner.php:265 +#: includes/class-agent-runner.php:326 +msgid "Run exceeded maximum wall time." +msgstr "" + +#: includes/class-agent-runner.php:344 +#: includes/class-agent-runner.php:420 +msgid "Background follow-up slices are disabled for this trigger." +msgstr "" + +#: includes/class-agent-runner.php:457 +#: includes/helpers/class-chat-helper.php:232 +msgid "Agent run failed." +msgstr "" + +#: includes/class-agent-runner.php:496 +#: includes/helpers/class-agent-loop-helper.php:331 +#: src/panel/services/realClient.js:598 +msgid "Action requires confirmation before continuing." +msgstr "" + +#: includes/class-agent-runner.php:497 +#: includes/helpers/class-agent-loop-helper.php:335 +#: src/panel/services/realClient.js:609 +msgid "I finished the background steps, but I did not receive a final text response. Please tell me to continue and I will pick up from here." +msgstr "" + #: includes/class-panel.php:116 msgid "Toggle ClawPress panel" msgstr "" @@ -663,7 +694,9 @@ msgstr "" #: includes/commands/handlers/class-setup-command-handler.php:393 #: includes/commands/handlers/class-test-command-handler.php:203 -#: includes/helpers/class-chat-helper.php:1063 +#: includes/helpers/class-agent-loop-helper.php:268 +#: includes/helpers/class-chat-helper.php:537 +#: includes/helpers/class-chat-helper.php:590 msgid "Unknown provider error." msgstr "" @@ -1086,103 +1119,136 @@ msgstr "" msgid "- tool_execution: unavailable (no registered abilities)" msgstr "" -#: includes/helpers/class-abilities-helper.php:206 +#: includes/helpers/class-abilities-helper.php:232 msgid "The requested tool is not allowlisted." msgstr "" -#: includes/helpers/class-abilities-helper.php:219 +#: includes/helpers/class-abilities-helper.php:245 msgid "The WordPress Abilities API is unavailable." msgstr "" -#: includes/helpers/class-abilities-helper.php:234 +#: includes/helpers/class-abilities-helper.php:260 msgid "The requested ability is not registered." msgstr "" -#: includes/helpers/class-abilities-helper.php:266 -msgid "This destructive action is pending batch confirmation." +#: includes/helpers/class-abilities-helper.php:290 +msgid "Tool execution is blocked by runtime policy." msgstr "" -#: includes/helpers/class-abilities-helper.php:291 -msgid "Explicit confirmation is required for this destructive action." +#: includes/helpers/class-abilities-helper.php:313 +msgid "Destructive tools are not allowed for this runtime trigger." msgstr "" -#: includes/helpers/class-abilities-helper.php:499 -msgid "Tool execution completed." +#: includes/helpers/class-abilities-helper.php:336 +msgid "File delete is blocked by runtime policy." msgstr "" -#: includes/helpers/class-chat-helper.php:671 -msgid "A destructive action is waiting for your confirmation." +#: includes/helpers/class-abilities-helper.php:363 +msgid "This destructive action is pending batch confirmation." msgstr "" -#: includes/helpers/class-chat-helper.php:674 -msgid "Action required." +#: includes/helpers/class-abilities-helper.php:388 +msgid "Explicit confirmation is required for this destructive action." +msgstr "" + +#. translators: %d: maximum number of tool calls executed per round. +#: includes/helpers/class-agent-loop-helper.php:917 +#, php-format +msgid "Tool call deferred because the per-round limit of %d was reached. Retry this call in the next round." msgstr "" #. translators: 1: tool name, 2: batch ID, 3: expiry time -#: includes/helpers/class-chat-helper.php:744 +#: includes/helpers/class-agent-loop-helper.php:1210 #, php-format msgid "Confirm batch `%2$s` to run `%1$s`. This batch expires at %3$s." msgstr "" #. translators: 1: total destructive calls, 2: batch ID, 3: comma-separated tool names, 4: expiry time -#: includes/helpers/class-chat-helper.php:752 +#: includes/helpers/class-agent-loop-helper.php:1218 #, php-format msgid "This reply queued %1$d destructive tool calls in batch `%2$s` (%3$s). Use Confirm All to execute the entire batch. This batch expires at %4$s." msgstr "" -#: includes/helpers/class-chat-helper.php:755 +#: includes/helpers/class-agent-loop-helper.php:1221 msgid "tools" msgstr "" -#: includes/helpers/class-chat-helper.php:763 +#: includes/helpers/class-agent-loop-helper.php:1229 #: src/panel/components/cards/UserConfirmationCard.jsx:8 msgid "User Confirmation Required" msgstr "" -#: includes/helpers/class-chat-helper.php:764 +#: includes/helpers/class-agent-loop-helper.php:1230 msgid "Destructive batch pending" msgstr "" -#: includes/helpers/class-chat-helper.php:770 +#: includes/helpers/class-agent-loop-helper.php:1236 msgid "Confirm All" msgstr "" -#: includes/helpers/class-chat-helper.php:771 +#: includes/helpers/class-agent-loop-helper.php:1237 msgid "Confirm Action" msgstr "" -#: includes/helpers/class-chat-helper.php:777 +#: includes/helpers/class-agent-loop-helper.php:1243 msgid "Decline" msgstr "" +#: includes/helpers/class-chat-helper.php:366 +msgid "Unable to persist paused run state." +msgstr "" + +#: includes/helpers/class-chat-helper.php:376 +msgid "Unable to release session after pausing run." +msgstr "" + +#: includes/helpers/class-chat-helper.php:388 +#: src/panel/components/PanelMessages.jsx:45 +#: src/panel/Panel.jsx:602 +#: src/panel/services/realClient.js:7 +msgid "I am still working on this" +msgstr "" + +#: includes/helpers/class-chat-helper.php:462 +msgid "A destructive action is waiting for your confirmation." +msgstr "" + +#: includes/helpers/class-chat-helper.php:465 +msgid "Action required." +msgstr "" + #. translators: %s: the original user message -#: includes/helpers/class-chat-helper.php:1002 +#: includes/helpers/class-chat-helper.php:476 #, php-format msgid "Offline mode: no configured AI provider was available. You said: \"%s\"" msgstr "" #. translators: %s: provider/transport error message -#: includes/helpers/class-chat-helper.php:1069 +#: includes/helpers/class-chat-helper.php:553 +#: includes/helpers/class-chat-helper.php:602 #, php-format msgid "AI request failed: %s" msgstr "" -#: includes/helpers/class-chat-helper.php:1074 +#: includes/helpers/class-chat-helper.php:569 +#: includes/helpers/class-chat-helper.php:618 +#: src/panel/components/cards/ErrorCard.jsx:7 +#: src/panel/Panel.jsx:144 +msgid "Request Error" +msgstr "" + +#: includes/helpers/class-chat-helper.php:571 +#: includes/helpers/class-chat-helper.php:620 #: src/panel/Panel.jsx:131 msgid "Request timed out" msgstr "" -#: includes/helpers/class-chat-helper.php:1075 +#: includes/helpers/class-chat-helper.php:572 +#: includes/helpers/class-chat-helper.php:621 #: src/panel/Panel.jsx:133 msgid "Provider error" msgstr "" -#: includes/helpers/class-chat-helper.php:1097 -#: src/panel/components/cards/ErrorCard.jsx:7 -#: src/panel/Panel.jsx:144 -msgid "Request Error" -msgstr "" - #: includes/helpers/class-settings-helper.php:142 msgid "No settings provided" msgstr "" @@ -1199,6 +1265,39 @@ msgstr "" msgid "Unable to create workspace directory structure." msgstr "" +#: includes/rest/class-agent-run-controller.php:233 +msgid "Session not found." +msgstr "" + +#: includes/rest/class-agent-run-controller.php:252 +msgid "Unable to create session for run." +msgstr "" + +#: includes/rest/class-agent-run-controller.php:292 +msgid "Unable to create run." +msgstr "" + +#: includes/rest/class-agent-run-controller.php:329 +msgid "Unable to create session for spawned agent." +msgstr "" + +#: includes/rest/class-agent-run-controller.php:354 +msgid "Unable to create run for spawned agent." +msgstr "" + +#: includes/rest/class-agent-run-controller.php:384 +#: includes/rest/class-agent-run-controller.php:431 +msgid "Run not found." +msgstr "" + +#: includes/rest/class-agent-run-controller.php:394 +msgid "Run is not in a retryable state." +msgstr "" + +#: includes/rest/class-agent-run-controller.php:403 +msgid "Unable to enqueue run." +msgstr "" + #: includes/rest/class-chat-controller.php:112 msgid "Message is required." msgstr "" @@ -1456,12 +1555,12 @@ msgid "Please confirm or decline this action." msgstr "" #: src/panel/components/cards/WelcomeCard.jsx:8 -#: src/panel/Panel.jsx:787 +#: src/panel/Panel.jsx:794 msgid "Welcome to ClawPress" msgstr "" #: src/panel/components/cards/WelcomeCard.jsx:12 -#: src/panel/Panel.jsx:791 +#: src/panel/Panel.jsx:798 msgid "Hello! I am ready to help with your WordPress tasks." msgstr "" @@ -1535,58 +1634,78 @@ msgid "Close panel" msgstr "" #. translators: 1: percentage used, 2: percentage left -#: src/panel/components/PanelInput.jsx:91 +#: src/panel/components/PanelInput.jsx:93 #, js-format msgid "%1$d%% used (%2$d%% left)" msgstr "" #. translators: 1: used tokens, 2: available context-window tokens -#: src/panel/components/PanelInput.jsx:99 +#: src/panel/components/PanelInput.jsx:101 #, js-format msgid "%1$s / %2$s tokens used" msgstr "" -#: src/panel/components/PanelInput.jsx:165 -#: src/panel/components/PanelInput.jsx:168 +#: src/panel/components/PanelInput.jsx:185 +#: src/panel/components/PanelInput.jsx:188 msgid "Suggestions" msgstr "" -#: src/panel/components/PanelInput.jsx:190 +#: src/panel/components/PanelInput.jsx:210 msgid "Ask me anything…" msgstr "" #. translators: 1: context usage summary, 2: token usage summary -#: src/panel/components/PanelInput.jsx:202 +#: src/panel/components/PanelInput.jsx:222 #, js-format msgid "Context window: %1$s. %2$s." msgstr "" -#: src/panel/components/PanelInput.jsx:222 +#: src/panel/components/PanelInput.jsx:242 msgid "Context window:" msgstr "" -#: src/panel/components/PanelInput.jsx:231 +#: src/panel/components/PanelInput.jsx:251 msgid "Codex automatically compacts its context" msgstr "" -#: src/panel/components/PanelInput.jsx:242 +#: src/panel/components/PanelInput.jsx:262 msgid "Stop" msgstr "" -#: src/panel/components/PanelInput.jsx:250 +#: src/panel/components/PanelInput.jsx:270 msgid "Send" msgstr "" -#: src/panel/components/PanelMessages.jsx:136 +#: src/panel/components/PanelMessages.jsx:46 +#: src/panel/services/realClient.js:8 +msgid "Yes, still working on it." +msgstr "" + +#: src/panel/components/PanelMessages.jsx:47 +#: src/panel/services/realClient.js:9 +msgid "Ok, this is taking long now. Still on it." +msgstr "" + +#: src/panel/components/PanelMessages.jsx:48 +#: src/panel/services/realClient.js:10 +msgid "Plot twist: still working on it. My keyboard is sweating." +msgstr "" + +#: src/panel/components/PanelMessages.jsx:52 +#: src/panel/services/realClient.js:14 +msgid "At this point even my coffee is worried, but I am still on it." +msgstr "" + +#: src/panel/components/PanelMessages.jsx:170 msgid "System" msgstr "" -#: src/panel/components/PanelMessages.jsx:137 -#: src/panel/components/PanelMessages.jsx:187 +#: src/panel/components/PanelMessages.jsx:171 +#: src/panel/components/PanelMessages.jsx:223 msgid "AGENT" msgstr "" -#: src/panel/components/PanelMessages.jsx:180 +#: src/panel/components/PanelMessages.jsx:216 msgid "Thinking" msgstr "" @@ -1670,8 +1789,8 @@ msgid "Network or API request error" msgstr "" #: src/panel/Panel.jsx:149 -#: src/panel/services/realClient.js:282 -#: src/panel/services/realClient.js:328 +#: src/panel/services/realClient.js:764 +#: src/panel/services/realClient.js:835 msgid "Chat request failed." msgstr "" @@ -1695,52 +1814,68 @@ msgstr "" msgid "Preparing tool plan…" msgstr "" -#: src/panel/Panel.jsx:601 +#: src/panel/Panel.jsx:608 msgid "Stream error." msgstr "" -#: src/panel/Panel.jsx:799 +#: src/panel/Panel.jsx:806 msgid "Start Setup" msgstr "" -#: src/panel/Panel.jsx:827 +#: src/panel/Panel.jsx:834 msgid "Unable to load chat history." msgstr "" -#: src/panel/Panel.jsx:898 +#: src/panel/Panel.jsx:905 msgid "Stream stopped." msgstr "" -#: src/panel/Panel.jsx:940 +#: src/panel/Panel.jsx:947 msgid "Tool execution failed." msgstr "" -#: src/panel/Panel.jsx:1007 -#: src/panel/Panel.jsx:1031 +#: src/panel/Panel.jsx:1014 +#: src/panel/Panel.jsx:1038 msgid "Invalid card action." msgstr "" -#: src/panel/Panel.jsx:1018 +#: src/panel/Panel.jsx:1025 msgid "Invalid card URL." msgstr "" #. translators: %s: selected mock scenario -#: src/panel/Panel.jsx:1074 +#: src/panel/Panel.jsx:1081 #, js-format msgid "Mock: %s" msgstr "" -#: src/panel/Panel.jsx:1164 +#: src/panel/Panel.jsx:1171 msgid "Resize panel" msgstr "" #. translators: %d: HTTP status code -#: src/panel/services/realClient.js:32 +#: src/panel/services/realClient.js:59 #, js-format msgid "Request failed (%d)" msgstr "" -#: src/panel/services/realClient.js:340 +#: src/panel/services/realClient.js:535 +msgid "Run failed." +msgstr "" + +#: src/panel/services/realClient.js:622 +msgid "Run ended with an error." +msgstr "" + +#: src/panel/services/realClient.js:653 +msgid "Run polling timed out before a terminal status was received." +msgstr "" + +#: src/panel/services/realClient.js:817 +msgid "Run entered progress mode without a valid run ID." +msgstr "" + +#: src/panel/services/realClient.js:847 msgid "Tool execution is not available in chat mode." msgstr "" diff --git a/package.json b/package.json index 20c612d..3bf6dd5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "languages", "vendor", "clawpress.php", + "composer.json", "README.md", "LICENSE" ], diff --git a/src/panel/Panel.jsx b/src/panel/Panel.jsx index 86431ce..5e60c7b 100644 --- a/src/panel/Panel.jsx +++ b/src/panel/Panel.jsx @@ -328,11 +328,34 @@ const Panel = () => { const ephemeralStatusIdRef = useRef( null ); const setEphemeralStatus = ( content ) => { + const text = + typeof content === 'string' && content.trim() ? content : ''; + if ( ! text ) { + return; + } + + const existingId = ephemeralStatusIdRef.current; + if ( existingId ) { + setMessages( ( prev ) => + prev.map( ( message ) => + message.id === existingId + ? { ...message, content: text } + : message + ) + ); + return; + } + const id = `status-${ Date.now() }-${ Math.random() }`; ephemeralStatusIdRef.current = id; setMessages( ( prev ) => [ ...prev, - { id, role: 'system', content, createdAt: ++timelineRef.current }, + { + id, + role: 'system', + content: text, + createdAt: ++timelineRef.current, + }, ] ); }; const clearEphemeralStatus = () => { @@ -524,9 +547,9 @@ const Panel = () => { }; const processStreamEvent = ( eventType, parsed ) => { - clearEphemeralStatus(); switch ( eventType ) { case 'delta': + clearEphemeralStatus(); if ( parsed.text ) { const next = `${ currentStreamTextRef.current || '' }${ parsed.text @@ -536,6 +559,7 @@ const Panel = () => { } break; case 'response_message': + clearEphemeralStatus(); if ( parsed?.text ) { appendMessage( parsed?.role === 'system' ? 'system' : 'assistant', @@ -544,6 +568,7 @@ const Panel = () => { } break; case 'response_card': + clearEphemeralStatus(); if ( parsed?.card ) { appendMessage( parsed?.role === 'system' ? 'system' : 'assistant', @@ -553,6 +578,7 @@ const Panel = () => { } break; case 'history_reset': + clearEphemeralStatus(); setMessages( [] ); setToolDialogs( [] ); setToolPlans( [] ); @@ -595,7 +621,15 @@ const Panel = () => { } setToolPlanningShown( false ); break; + case 'run_progress': + setEphemeralStatus( + typeof parsed?.text === 'string' && parsed.text.trim() + ? parsed.text + : __( 'I am still working on this', 'clawpress' ) + ); + break; case 'error': + clearEphemeralStatus(); appendMessage( 'system', parsed?.error || __( 'Stream error.', 'clawpress' ), @@ -604,6 +638,7 @@ const Panel = () => { ); break; case 'done': + clearEphemeralStatus(); finishStream(); break; } @@ -660,7 +695,6 @@ const Panel = () => { const handleStreamEvent = ( eventType, parsed ) => { setWaitingForResponse( false ); - clearEphemeralStatus(); eventQueueRef.current.push( { type: eventType, payload: parsed } ); processEventQueue(); }; @@ -1177,6 +1211,7 @@ const Panel = () => { input={ input } onInputChange={ ( e ) => setInput( e.target.value ) } onSend={ sendPrompt } + panelOpen={ open } suggestions={ suggestions } contextUsage={ contextUsage } onSendSuggestion={ ( suggestion ) => diff --git a/src/panel/components/PanelInput.jsx b/src/panel/components/PanelInput.jsx index 8b49948..f4cdc6c 100644 --- a/src/panel/components/PanelInput.jsx +++ b/src/panel/components/PanelInput.jsx @@ -1,4 +1,4 @@ -import { useRef } from '@wordpress/element'; +import { useEffect, useRef } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; const formatCompactTokens = ( rawValue ) => { @@ -36,6 +36,7 @@ const PanelInput = ( { onSend, onStop, streaming, + panelOpen, suggestions, contextUsage, onSendSuggestion, @@ -43,6 +44,7 @@ const PanelInput = ( { onHistoryDown, } ) => { const textareaRef = useRef( null ); + const wasStreamingRef = useRef( streaming ); const normalizedUsedTokens = Number( contextUsage?.usedTokens ); const normalizedContextWindowTokens = Number( contextUsage?.contextWindowTokens @@ -102,6 +104,24 @@ const PanelInput = ( { ) : ''; + useEffect( () => { + const wasStreaming = wasStreamingRef.current; + wasStreamingRef.current = streaming; + + if ( ! wasStreaming || streaming || ! panelOpen ) { + return; + } + + const textarea = textareaRef.current; + if ( ! textarea ) { + return; + } + + textarea.focus(); + const end = textarea.value.length; + textarea.setSelectionRange( end, end ); + }, [ streaming, panelOpen ] ); + const handleKeyDown = ( e ) => { if ( e.key === 'Enter' && ! e.shiftKey ) { e.preventDefault(); diff --git a/src/panel/components/PanelMessages.jsx b/src/panel/components/PanelMessages.jsx index 129b2a0..161d371 100644 --- a/src/panel/components/PanelMessages.jsx +++ b/src/panel/components/PanelMessages.jsx @@ -41,6 +41,22 @@ const PanelMessages = ( { onSendCardAction, } ) => { const containerRef = useRef( null ); + const inProgressStatusMessages = new Set( [ + __( 'I am still working on this', 'clawpress' ), + __( 'Yes, still working on it.', 'clawpress' ), + __( 'Ok, this is taking long now. Still on it.', 'clawpress' ), + __( + 'Plot twist: still working on it. My keyboard is sweating.', + 'clawpress' + ), + __( + 'At this point even my coffee is worried, but I am still on it.', + 'clawpress' + ), + ] ); + const isInProgressStatusMessage = ( content ) => + typeof content === 'string' && + inProgressStatusMessages.has( content.trim() ); useEffect( () => { const container = containerRef.current; @@ -72,6 +88,11 @@ const PanelMessages = ( { null; const latestMessageIndex = messages.length - 1; + const latestMessage = + latestMessageIndex >= 0 ? messages[ latestMessageIndex ] : null; + const latestInProgressStatusVisible = isInProgressStatusMessage( + latestMessage?.content || '' + ); const items = [ ...messages.map( ( message, index ) => ( { @@ -111,16 +132,28 @@ const PanelMessages = ( { { items.map( ( item ) => item.type === 'message' ? ( ( () => { - const isSystem = item.data.role === 'system'; - const isAssistant = item.data.role === 'assistant'; + const content = item.data.content || ''; + const isInProgressStatus = + isInProgressStatusMessage( content ); + if ( + isInProgressStatus && + item.messageIndex !== latestMessageIndex + ) { + return null; + } + const messageRole = isInProgressStatus + ? 'system' + : item.data.role; + const isSystem = messageRole === 'system'; + const isAssistant = messageRole === 'assistant'; const hasCard = item.data.card && typeof item.data.card === 'object'; const isLatestMessageCard = hasCard && item.messageIndex === latestMessageIndex; - const content = item.data.content || ''; const hasEllipsis = /(\.\.\.|…)\s*$/.test( content ); - const showThinking = isSystem && hasEllipsis; + const showThinking = + ( isSystem && hasEllipsis ) || isInProgressStatus; const displayContent = showThinking ? content.replace( /\s*(\.\.\.|…)\s*$/, '' ) : content; @@ -128,9 +161,10 @@ const PanelMessages = ( { return (
- { isSystem || isAssistant ? ( + { ( isSystem || isAssistant ) && + ! isInProgressStatus ? (
{ isSystem ? __( 'System', 'clawpress' ) @@ -174,7 +208,9 @@ const PanelMessages = ( { /> ) ) } - { waitingForResponse && ! currentStreamText ? ( + { waitingForResponse && + ! currentStreamText && + ! latestInProgressStatusVisible ? (
{ __( 'Thinking', 'clawpress' ) } diff --git a/src/panel/components/PanelMessages.scss b/src/panel/components/PanelMessages.scss index ce083a0..4f12172 100644 --- a/src/panel/components/PanelMessages.scss +++ b/src/panel/components/PanelMessages.scss @@ -159,16 +159,29 @@ content: '...'; display: inline-block; margin-left: 2px; - width: 1.2em; + width: 1ch; white-space: nowrap; overflow: hidden; vertical-align: baseline; line-height: 1; - clip-path: inset(0 100% 0 0); - animation: clawpress-thinking-dots 1.2s steps(3, end) infinite; + animation: clawpress-thinking-dots 1.2s steps(1, end) infinite; } @keyframes clawpress-thinking-dots { - 0% { clip-path: inset(0 100% 0 0); } - 100% { clip-path: inset(0 0 0 0); } + 0%, + 24.99% { + width: 1ch; + } + 25%, + 49.99% { + width: 2ch; + } + 50%, + 74.99% { + width: 3ch; + } + 75%, + 100% { + width: 1ch; + } } diff --git a/src/panel/services/realClient.js b/src/panel/services/realClient.js index 812d41a..dfe5626 100644 --- a/src/panel/services/realClient.js +++ b/src/panel/services/realClient.js @@ -1,5 +1,32 @@ import { __, sprintf } from '@wordpress/i18n'; +const RUN_POLL_INTERVAL_MS = 1000; +const RUN_POLL_MAX_SECONDS = 180; +const RUN_PROGRESS_MESSAGE_STEP_SECONDS = 8; +const RUN_PROGRESS_MESSAGES = [ + __( 'I am still working on this', 'clawpress' ), + __( 'Yes, still working on it.', 'clawpress' ), + __( 'Ok, this is taking long now. Still on it.', 'clawpress' ), + __( + 'Plot twist: still working on it. My keyboard is sweating.', + 'clawpress' + ), + __( + 'At this point even my coffee is worried, but I am still on it.', + 'clawpress' + ), +]; +const TERMINAL_RUN_STATUSES = new Set( [ + 'done', + 'success', + 'error', + 'timeout', + 'requires_confirmation', + 'failed', + 'cancelled', + 'canceled', +] ); + const requestJson = async ( { url, method = 'GET', nonce, body, signal } ) => { const res = await fetch( url, { method, @@ -38,6 +65,9 @@ const requestJson = async ( { url, method = 'GET', nonce, body, signal } ) => { return payload; }; +const isObjectRecord = ( value ) => + Boolean( value ) && typeof value === 'object' && ! Array.isArray( value ); + const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => { const sendMessage = ( message, signal ) => requestJson( { @@ -48,6 +78,22 @@ const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => { signal, } ); + const getRunStatus = ( runId, signal ) => + requestJson( { + url: `${ restBase }/agent/runs/${ runId }`, + method: 'GET', + nonce, + signal, + } ); + + const getRunEvents = ( runId, after, signal ) => + requestJson( { + url: `${ restBase }/agent/runs/${ runId }/events?after=${ after }&limit=100`, + method: 'GET', + nonce, + signal, + } ); + const getHistory = () => requestJson( { url: `${ restBase }/chat/history`, @@ -77,6 +123,39 @@ const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => { body: state, } ); + const waitForNextPoll = ( signal ) => + new Promise( ( resolve, reject ) => { + const timerId = setTimeout( () => { + signal?.removeEventListener( 'abort', onAbort ); + resolve(); + }, RUN_POLL_INTERVAL_MS ); + + const onAbort = () => { + clearTimeout( timerId ); + signal?.removeEventListener( 'abort', onAbort ); + const abortError = new Error( 'Aborted' ); + abortError.name = 'AbortError'; + reject( abortError ); + }; + + signal?.addEventListener( 'abort', onAbort, { once: true } ); + } ); + + const getRunProgressMessage = ( elapsedSeconds ) => { + const normalizedElapsedSeconds = Number.isFinite( + Number( elapsedSeconds ) + ) + ? Math.max( 0, Number( elapsedSeconds ) ) + : 0; + const index = Math.min( + Math.floor( + normalizedElapsedSeconds / RUN_PROGRESS_MESSAGE_STEP_SECONDS + ), + RUN_PROGRESS_MESSAGES.length - 1 + ); + return RUN_PROGRESS_MESSAGES[ index ] || RUN_PROGRESS_MESSAGES[ 0 ]; + }; + const normalizeCard = ( rawCard ) => { if ( ! rawCard || typeof rawCard !== 'object' ) { return null; @@ -221,9 +300,411 @@ const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => { }; }; + const normalizeRunStatus = ( value ) => + typeof value === 'string' ? value.trim().toLowerCase() : ''; + + const buildToolCallDedupKey = ( call ) => { + if ( ! call || typeof call !== 'object' ) { + return ''; + } + + const name = typeof call.name === 'string' ? call.name.trim() : ''; + if ( ! name ) { + return ''; + } + + const status = + typeof call.status === 'string' + ? call.status.trim().toLowerCase() + : ''; + const message = + typeof call.message === 'string' ? call.message.trim() : ''; + const round = Number.isFinite( Number( call.round ) ) + ? Math.max( 1, Math.round( Number( call.round ) ) ) + : 1; + const sequence = Number.isFinite( Number( call.sequence ) ) + ? Math.max( 1, Math.round( Number( call.sequence ) ) ) + : 1; + + return `${ name }|${ status }|${ round }|${ sequence }|${ message }`; + }; + + const emitToolCallIfNew = ( call, index, total, seenToolCallKeys ) => { + const dedupKey = buildToolCallDedupKey( call ); + if ( dedupKey && seenToolCallKeys instanceof Set ) { + if ( seenToolCallKeys.has( dedupKey ) ) { + return; + } + seenToolCallKeys.add( dedupKey ); + } + + onEvent( 'tool_call', { + call, + index, + total, + } ); + }; + + const emitToolCallEvents = ( events, seenToolCallKeys ) => { + if ( ! Array.isArray( events ) || events.length === 0 ) { + return; + } + + const canonicalToolEvents = events.filter( + ( event ) => + isObjectRecord( event ) && + typeof event.event_type === 'string' && + event.event_type === 'agent.tool_call' + ); + if ( canonicalToolEvents.length > 0 ) { + canonicalToolEvents.forEach( ( event, index ) => { + const payload = isObjectRecord( event.payload ) + ? event.payload + : {}; + const status = normalizeRunStatus( payload.status ); + + let toolName = 'tool_call'; + if ( + typeof payload.tool_name === 'string' && + payload.tool_name.trim() + ) { + toolName = payload.tool_name.trim(); + } else if ( + typeof payload.ability_name === 'string' && + payload.ability_name.trim() + ) { + toolName = payload.ability_name.trim(); + } + + let normalizedStatus = 'success'; + if ( + status === 'error' || + status === 'requires_confirmation' + ) { + normalizedStatus = status; + } + + const detailMessage = + typeof payload.message === 'string' && + payload.message.trim() + ? payload.message.trim() + : ''; + + const call = { + name: toolName, + ability: + typeof payload.ability_name === 'string' && + payload.ability_name.trim() + ? payload.ability_name.trim() + : null, + args: {}, + status: normalizedStatus, + message: detailMessage || null, + round: Number.isFinite( Number( payload.round ) ) + ? Math.max( 1, Math.round( Number( payload.round ) ) ) + : 1, + sequence: Number.isFinite( Number( payload.sequence ) ) + ? Math.max( + 1, + Math.round( Number( payload.sequence ) ) + ) + : index + 1, + requiresConfirmation: status === 'requires_confirmation', + }; + + emitToolCallIfNew( + call, + index + 1, + canonicalToolEvents.length, + seenToolCallKeys + ); + } ); + return; + } + + // Fallback for older backends that only emit `tool_call` events. + const toolEvents = events.filter( + ( event ) => + isObjectRecord( event ) && + typeof event.event_type === 'string' && + event.event_type === 'tool_call' + ); + if ( toolEvents.length === 0 ) { + return; + } + + toolEvents.forEach( ( event, index ) => { + const payload = isObjectRecord( event.payload ) + ? event.payload + : {}; + const status = normalizeRunStatus( payload.status ); + const result = isObjectRecord( payload.result ) + ? payload.result + : {}; + const error = isObjectRecord( payload.error ) ? payload.error : {}; + let detailMessage = ''; + if ( typeof error.message === 'string' && error.message.trim() ) { + detailMessage = error.message.trim(); + } else if ( + typeof result.message === 'string' && + result.message.trim() + ) { + detailMessage = result.message.trim(); + } + + let toolName = 'tool_call'; + if ( + typeof payload.tool_name === 'string' && + payload.tool_name.trim() + ) { + toolName = payload.tool_name.trim(); + } else if ( + typeof payload.ability_name === 'string' && + payload.ability_name.trim() + ) { + toolName = payload.ability_name.trim(); + } + + let normalizedStatus = 'success'; + if ( status === 'error' || status === 'requires_confirmation' ) { + normalizedStatus = status; + } + + const call = { + name: toolName, + ability: + typeof payload.ability_name === 'string' && + payload.ability_name.trim() + ? payload.ability_name.trim() + : null, + args: {}, + status: normalizedStatus, + message: detailMessage || null, + round: Number.isFinite( Number( payload.round ) ) + ? Math.max( 1, Math.round( Number( payload.round ) ) ) + : 1, + sequence: Number.isFinite( Number( payload.sequence ) ) + ? Math.max( 1, Math.round( Number( payload.sequence ) ) ) + : index + 1, + requiresConfirmation: status === 'requires_confirmation', + }; + + emitToolCallIfNew( + call, + index + 1, + toolEvents.length, + seenToolCallKeys + ); + } ); + }; + + const emitRuntimeResult = ( + runtimeResult, + initialReply, + seenToolCallKeys + ) => { + if ( ! isObjectRecord( runtimeResult ) ) { + return false; + } + + const contextUsage = normalizeContextUsage( runtimeResult.context ); + if ( contextUsage ) { + onEvent( 'context_usage', { context: contextUsage } ); + } + + const toolCalls = Array.isArray( runtimeResult.tool_calls ) + ? runtimeResult.tool_calls + .map( ( rawCall ) => normalizeToolCall( rawCall ) ) + .filter( Boolean ) + : []; + toolCalls.forEach( ( call, index ) => { + emitToolCallIfNew( + call, + index + 1, + toolCalls.length, + seenToolCallKeys + ); + } ); + + if ( isObjectRecord( runtimeResult.error ) ) { + const runtimeError = runtimeResult.error; + const errorMessage = + typeof runtimeError.message === 'string' && + runtimeError.message.trim() + ? runtimeError.message.trim() + : __( 'Run failed.', 'clawpress' ); + onEvent( 'error', { + error: errorMessage, + type: + typeof runtimeError.type === 'string' && + runtimeError.type.trim() + ? runtimeError.type.trim() + : 'provider', + card: normalizeCard( runtimeResult.card ), + } ); + return true; + } + + const reply = + typeof runtimeResult.assistant_text === 'string' + ? runtimeResult.assistant_text.trim() + : ''; + const card = normalizeCard( runtimeResult.card ); + if ( card ) { + onEvent( 'response_card', { + card, + text: reply, + role: 'assistant', + } ); + return true; + } + + if ( reply && reply !== initialReply ) { + onEvent( 'response_message', { + text: reply, + role: 'assistant', + } ); + return true; + } + + return false; + }; + + const emitRunTerminalOutcome = ( + runPayload, + initialReply, + seenToolCallKeys + ) => { + const runMeta = isObjectRecord( runPayload?.meta ) + ? runPayload.meta + : {}; + let runtimeResult = null; + if ( isObjectRecord( runMeta.result ) ) { + runtimeResult = runMeta.result; + } else if ( isObjectRecord( runMeta.last_result ) ) { + runtimeResult = runMeta.last_result; + } + + if ( + emitRuntimeResult( runtimeResult, initialReply, seenToolCallKeys ) + ) { + return; + } + + const runStatus = normalizeRunStatus( runPayload?.status ); + + if ( runStatus === 'requires_confirmation' ) { + onEvent( 'response_message', { + text: __( + 'Action requires confirmation before continuing.', + 'clawpress' + ), + role: 'assistant', + } ); + return; + } + + if ( runStatus === 'done' || runStatus === 'success' ) { + onEvent( 'response_message', { + text: __( + 'I finished the background steps, but I did not receive a final text response. Please tell me to continue and I will pick up from here.', + 'clawpress' + ), + role: 'assistant', + } ); + return; + } + + const errorMessage = + typeof runPayload?.error_message === 'string' && + runPayload.error_message.trim() + ? runPayload.error_message.trim() + : __( 'Run ended with an error.', 'clawpress' ); + + onEvent( 'error', { + error: errorMessage, + type: runStatus === 'timeout' ? 'timeout' : 'provider', + } ); + }; + + const pollRunUntilTerminal = async ( { + runId, + initialReply, + signal, + initialEventsCursor = 0, + seenToolCallKeys = null, + } ) => { + let afterEventId = Number.isFinite( Number( initialEventsCursor ) ) + ? Math.max( 0, Math.round( Number( initialEventsCursor ) ) ) + : 0; + const startedAt = Date.now(); + let lastProgressMessage = ''; + + while ( true ) { + if ( signal?.aborted ) { + const abortError = new Error( 'Aborted' ); + abortError.name = 'AbortError'; + throw abortError; + } + + const elapsedSeconds = ( Date.now() - startedAt ) / 1000; + if ( elapsedSeconds >= RUN_POLL_MAX_SECONDS ) { + onEvent( 'error', { + error: __( + 'Run polling timed out before a terminal status was received.', + 'clawpress' + ), + type: 'timeout', + } ); + return; + } + + try { + const eventPayload = await getRunEvents( + runId, + afterEventId, + signal + ); + const events = Array.isArray( eventPayload?.events ) + ? eventPayload.events + : []; + emitToolCallEvents( events, seenToolCallKeys ); + + const nextCursor = Number( eventPayload?.next_cursor ); + if ( Number.isFinite( nextCursor ) && nextCursor > 0 ) { + afterEventId = Math.max( afterEventId, nextCursor ); + } + } catch ( err ) { + if ( err?.name === 'AbortError' ) { + throw err; + } + } + + const runPayload = await getRunStatus( runId, signal ); + const status = normalizeRunStatus( runPayload?.status ); + if ( TERMINAL_RUN_STATUSES.has( status ) ) { + emitRunTerminalOutcome( + runPayload, + initialReply, + seenToolCallKeys + ); + return; + } + + const progressMessage = getRunProgressMessage( elapsedSeconds ); + if ( progressMessage && progressMessage !== lastProgressMessage ) { + onEvent( 'run_progress', { text: progressMessage } ); + lastProgressMessage = progressMessage; + } + + await waitForNextPoll( signal ); + } + }; + // Keep a stream-compatible interface for the existing panel flow. const stream = ( prompt ) => { const controller = new AbortController(); + const seenToolCallKeys = new Set(); ( async () => { try { @@ -256,11 +737,12 @@ const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => { : []; toolCalls.forEach( ( call, index ) => { - onEvent( 'tool_call', { + emitToolCallIfNew( call, - index: index + 1, - total: toolCalls.length, - } ); + index + 1, + toolCalls.length, + seenToolCallKeys + ); } ); const responseError = @@ -316,6 +798,31 @@ const createRealClient = ( { restBase, nonce, onEvent, onDone, onError } ) => { } ); } + const runStatus = normalizeRunStatus( response?.meta?.status ); + const runId = Number( response?.meta?.run_id ); + const initialEventsCursor = Number( + response?.meta?.events_cursor + ); + if ( runStatus === 'in_progress' ) { + if ( Number.isFinite( runId ) && runId > 0 ) { + await pollRunUntilTerminal( { + runId, + initialReply: reply, + signal: controller.signal, + initialEventsCursor, + seenToolCallKeys, + } ); + } else { + onEvent( 'error', { + error: __( + 'Run entered progress mode without a valid run ID.', + 'clawpress' + ), + type: 'request', + } ); + } + } + onDone?.( { aborted: false } ); } catch ( err ) { if ( err?.name === 'AbortError' ) { diff --git a/tests/Support/AgentRuntimeWpdb.php b/tests/Support/AgentRuntimeWpdb.php new file mode 100644 index 0000000..be6640e --- /dev/null +++ b/tests/Support/AgentRuntimeWpdb.php @@ -0,0 +1,463 @@ +> */ + public array $sessions = []; + + /** @var array> */ + public array $runs = []; + + /** @var array> */ + public array $events = []; + + /** @var array */ + public array $last_prepare_args = []; + + public bool $fail_session_update = false; + + public bool $simulate_idempotency_race = false; + + public string $last_error = ''; + + private bool $in_transaction = false; + + /** @var array{sessions:array>,runs:array>,events:array>,insert_id:int}|null */ + private ?array $transaction_snapshot = null; + + public function get_charset_collate(): string { + return 'DEFAULT CHARSET=utf8mb4'; + } + + /** + * @param string $table Table name. + * @param array $data Data payload. + * @param array $format Format list. + * @return int|false + */ + public function insert( string $table, array $data, array $format ) { + unset( $format ); + $this->last_error = ''; + + if ( false !== strpos( $table, 'agent_runs' ) ) { + $idempotency_key = isset( $data['idempotency_key'] ) ? trim( (string) $data['idempotency_key'] ) : ''; + $session_id = isset( $data['session_id'] ) ? (int) $data['session_id'] : 0; + if ( $session_id > 0 && '' !== $idempotency_key ) { + $existing_run = $this->find_run_by_idempotency_key( $session_id, $idempotency_key ); + if ( null === $existing_run && $this->simulate_idempotency_race ) { + $this->simulate_idempotency_race = false; + ++$this->insert_id; + $data['id'] = $this->insert_id; + $this->runs[ $this->insert_id ] = $data; + $this->last_error = 'Duplicate entry for key session_idempotency_key'; + return false; + } + + if ( null !== $existing_run ) { + $this->last_error = 'Duplicate entry for key session_idempotency_key'; + return false; + } + } + } + + ++$this->insert_id; + $data['id'] = $this->insert_id; + + if ( false !== strpos( $table, 'agent_sessions' ) ) { + $this->sessions[ $this->insert_id ] = $data; + return 1; + } + + if ( false !== strpos( $table, 'agent_runs' ) ) { + $this->runs[ $this->insert_id ] = $data; + return 1; + } + + if ( false !== strpos( $table, 'agent_events' ) ) { + $this->events[ $this->insert_id ] = $data; + return 1; + } + + return false; + } + + /** + * @param string $table Table name. + * @param array $data Data payload. + * @param array $where Where payload. + * @param array|null $format Formats. + * @param array|null $where_format Where formats. + * @return int|false + */ + public function update( string $table, array $data, array $where, ?array $format = null, ?array $where_format = null ) { + unset( $format, $where_format ); + + $target = false !== strpos( $table, 'agent_sessions' ) ? 'sessions' : ( false !== strpos( $table, 'agent_runs' ) ? 'runs' : '' ); + if ( '' === $target ) { + return false; + } + + if ( $this->fail_session_update && 'sessions' === $target ) { + return false; + } + + $updated = 0; + foreach ( $this->{$target} as $id => $row ) { + $matches = true; + foreach ( $where as $key => $value ) { + $current = $row[ $key ] ?? null; + if ( (string) $current !== (string) $value ) { + $matches = false; + break; + } + } + + if ( ! $matches ) { + continue; + } + + $this->{$target}[ $id ] = array_merge( $row, $data ); + ++$updated; + } + + return $updated; + } + + /** + * @return int|false + */ + public function query( string $sql ) { + $sql_upper = strtoupper( trim( $sql ) ); + + if ( 'START TRANSACTION' === $sql_upper ) { + $this->in_transaction = true; + $this->transaction_snapshot = [ + 'sessions' => $this->sessions, + 'runs' => $this->runs, + 'events' => $this->events, + 'insert_id' => $this->insert_id, + ]; + return 1; + } + + if ( 'COMMIT' === $sql_upper ) { + $this->in_transaction = false; + $this->transaction_snapshot = null; + return 1; + } + + if ( 'ROLLBACK' === $sql_upper ) { + if ( $this->in_transaction && is_array( $this->transaction_snapshot ) ) { + $this->sessions = $this->transaction_snapshot['sessions']; + $this->runs = $this->transaction_snapshot['runs']; + $this->events = $this->transaction_snapshot['events']; + $this->insert_id = $this->transaction_snapshot['insert_id']; + } + + $this->in_transaction = false; + $this->transaction_snapshot = null; + return 1; + } + + if ( str_starts_with( $sql_upper, 'UPDATE' ) && false !== strpos( $sql_upper, 'AGENT_SESSIONS' ) ) { + if ( $this->fail_session_update ) { + return false; + } + + $has_paused_clause = count( $this->last_prepare_args ) >= 7; + $session_id_index = $has_paused_clause ? 6 : 5; + $next_run_index = $has_paused_clause ? 4 : 3; + $updated_index = $has_paused_clause ? 5 : 4; + $session_id = isset( $this->last_prepare_args[ $session_id_index ] ) ? (int) $this->last_prepare_args[ $session_id_index ] : 0; + if ( $session_id <= 0 || ! isset( $this->sessions[ $session_id ] ) ) { + return 0; + } + + $run_status = isset( $this->last_prepare_args[1] ) ? (string) $this->last_prepare_args[1] : ''; + $failures = (int) ( $this->sessions[ $session_id ]['consecutive_failures'] ?? 0 ); + + if ( in_array( $run_status, [ 'success', 'done', 'requires_confirmation' ], true ) ) { + $failures = 0; + } elseif ( 'paused' !== $run_status ) { + ++$failures; + } + + $this->sessions[ $session_id ]['last_run_at_gmt'] = isset( $this->last_prepare_args[0] ) ? (string) $this->last_prepare_args[0] : null; + $this->sessions[ $session_id ]['last_run_status'] = $run_status; + $this->sessions[ $session_id ]['consecutive_failures'] = $failures; + $this->sessions[ $session_id ]['next_run_at_gmt'] = $this->last_prepare_args[ $next_run_index ] ?? null; + $this->sessions[ $session_id ]['updated_at_gmt'] = isset( $this->last_prepare_args[ $updated_index ] ) ? (string) $this->last_prepare_args[ $updated_index ] : null; + return 1; + } + + return false; + } + + /** + * @param string $query Query string. + * @param array|mixed ...$args Prepare args. + */ + public function prepare( string $query, ...$args ): string { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + + $prepared_query = $query; + $non_identifier_arguments = []; + + foreach ( $args as $argument ) { + if ( false !== strpos( $prepared_query, '%i' ) ) { + $identifier = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $argument ); + $prepared_query = preg_replace( '/%i/', (string) $identifier, $prepared_query, 1 ) ?? $prepared_query; + continue; + } + + $non_identifier_arguments[] = $argument; + } + + $this->last_prepare_args = $non_identifier_arguments; + return $prepared_query; + } + + /** + * @return array|null + */ + public function get_row( string $query, string $output ) { + unset( $output ); + + if ( false !== strpos( $query, 'idempotency_key = %s' ) ) { + $session_id = isset( $this->last_prepare_args[0] ) ? (int) $this->last_prepare_args[0] : 0; + $idempotency_key = isset( $this->last_prepare_args[1] ) ? (string) $this->last_prepare_args[1] : ''; + if ( $session_id <= 0 || '' === $idempotency_key ) { + return null; + } + + $matching = array_values( + array_filter( + $this->runs, + static fn( array $row ): bool => (int) ( $row['session_id'] ?? 0 ) === $session_id + && (string) ( $row['idempotency_key'] ?? '' ) === $idempotency_key + ) + ); + + if ( [] === $matching ) { + return null; + } + + usort( + $matching, + static fn( array $left, array $right ): int => (int) ( $right['id'] ?? 0 ) <=> (int) ( $left['id'] ?? 0 ) + ); + + return $matching[0]; + } + + $id = isset( $this->last_prepare_args[0] ) ? (int) $this->last_prepare_args[0] : 0; + if ( $id <= 0 ) { + return null; + } + + if ( false !== strpos( $query, 'agent_sessions' ) ) { + return $this->sessions[ $id ] ?? null; + } + + if ( false !== strpos( $query, 'agent_runs' ) ) { + return $this->runs[ $id ] ?? null; + } + + if ( false !== strpos( $query, 'agent_events' ) ) { + return $this->events[ $id ] ?? null; + } + + return null; + } + + /** + * @return array> + */ + public function get_results( string $query, string $output ): array { + unset( $output ); + + if ( false !== strpos( $query, 'agent_runs' ) ) { + return $this->get_runnable_runs(); + } + + if ( false !== strpos( $query, 'agent_events' ) ) { + return $this->get_events_rows( $query ); + } + + return []; + } + + /** + * @return array> + */ + private function get_runnable_runs(): array { + $now_gmt = isset( $this->last_prepare_args[0] ) ? (string) $this->last_prepare_args[0] : gmdate( 'Y-m-d H:i:s' ); + $limit = isset( $this->last_prepare_args[2] ) ? (int) $this->last_prepare_args[2] : ( isset( $this->last_prepare_args[1] ) ? (int) $this->last_prepare_args[1] : 20 ); + $limit = max( 1, $limit ); + $now_ts = strtotime( $now_gmt ); + if ( false === $now_ts ) { + $now_ts = time(); + } + + $rows = array_values( + array_filter( + $this->runs, + static function ( array $row ) use ( $now_ts ): bool { + $status = isset( $row['status'] ) ? (string) $row['status'] : ''; + if ( in_array( $status, [ 'queued', 'paused' ], true ) ) { + $retry_at = isset( $row['next_retry_at_gmt'] ) ? (string) $row['next_retry_at_gmt'] : ''; + if ( '' === $retry_at ) { + return true; + } + + $retry_ts = strtotime( $retry_at ); + if ( false === $retry_ts ) { + return true; + } + + return $retry_ts <= $now_ts; + } + + if ( 'running' === $status ) { + $lock_expires_at = isset( $row['lock_expires_at_gmt'] ) ? (string) $row['lock_expires_at_gmt'] : ''; + if ( '' === $lock_expires_at ) { + return false; + } + + $lock_expires_ts = strtotime( $lock_expires_at ); + if ( false === $lock_expires_ts ) { + return false; + } + + return $lock_expires_ts <= $now_ts; + } + + return false; + } + ) + ); + + usort( + $rows, + static function ( array $left, array $right ): int { + $left_ts = strtotime( (string) ( $left['created_at_gmt'] ?? '' ) ) ?: 0; + $right_ts = strtotime( (string) ( $right['created_at_gmt'] ?? '' ) ) ?: 0; + if ( $left_ts === $right_ts ) { + return (int) ( $left['id'] ?? 0 ) <=> (int) ( $right['id'] ?? 0 ); + } + + return $left_ts <=> $right_ts; + } + ); + + return array_slice( $rows, 0, $limit ); + } + + /** + * @return array> + */ + private function get_events_rows( string $query ): array { + $arg_index = 0; + $after_id = isset( $this->last_prepare_args[ $arg_index ] ) ? (int) $this->last_prepare_args[ $arg_index ] : 0; + ++$arg_index; + + $run_id = null; + if ( false !== strpos( $query, 'run_id = %d' ) ) { + $run_id = isset( $this->last_prepare_args[ $arg_index ] ) ? (int) $this->last_prepare_args[ $arg_index ] : null; + ++$arg_index; + } + + $session_id = null; + if ( false !== strpos( $query, 'session_id = %d' ) ) { + $session_id = isset( $this->last_prepare_args[ $arg_index ] ) ? (int) $this->last_prepare_args[ $arg_index ] : null; + ++$arg_index; + } + + $event_type = null; + if ( false !== strpos( $query, 'event_type = %s' ) ) { + $event_type = isset( $this->last_prepare_args[ $arg_index ] ) ? (string) $this->last_prepare_args[ $arg_index ] : null; + ++$arg_index; + } + + $limit = isset( $this->last_prepare_args[ $arg_index ] ) ? (int) $this->last_prepare_args[ $arg_index ] : 100; + $limit = max( 1, $limit ); + + $rows = array_values( + array_filter( + $this->events, + static function ( array $row ) use ( $after_id, $run_id, $session_id, $event_type ): bool { + $id = isset( $row['id'] ) ? (int) $row['id'] : 0; + if ( $id <= $after_id ) { + return false; + } + + if ( null !== $run_id && (int) ( $row['run_id'] ?? 0 ) !== $run_id ) { + return false; + } + + if ( null !== $session_id && (int) ( $row['session_id'] ?? 0 ) !== $session_id ) { + return false; + } + + if ( null !== $event_type && (string) ( $row['event_type'] ?? '' ) !== $event_type ) { + return false; + } + + return true; + } + ) + ); + + usort( + $rows, + static fn( array $left, array $right ): int => (int) ( $left['id'] ?? 0 ) <=> (int) ( $right['id'] ?? 0 ) + ); + + return array_slice( $rows, 0, $limit ); + } + + /** + * Find run by session + idempotency key. + * + * @param int $session_id Session identifier. + * @param string $idempotency_key Idempotency key. + * @return array|null + */ + private function find_run_by_idempotency_key( int $session_id, string $idempotency_key ): ?array { + $matching = array_values( + array_filter( + $this->runs, + static fn( array $row ): bool => (int) ( $row['session_id'] ?? 0 ) === $session_id + && (string) ( $row['idempotency_key'] ?? '' ) === $idempotency_key + ) + ); + + if ( [] === $matching ) { + return null; + } + + usort( + $matching, + static fn( array $left, array $right ): int => (int) ( $right['id'] ?? 0 ) <=> (int) ( $left['id'] ?? 0 ) + ); + + return $matching[0]; + } +} diff --git a/tests/Support/WordPressStubs.php b/tests/Support/WordPressStubs.php index 8afddcf..cd98748 100644 --- a/tests/Support/WordPressStubs.php +++ b/tests/Support/WordPressStubs.php @@ -52,6 +52,12 @@ final class WordPress_Stubs { /** @var array> */ public static array $scheduled_actions = array(); + /** @var array> */ + public static array $single_scheduled_actions = array(); + + /** @var array> */ + public static array $async_actions = array(); + /** @var array> */ public static array $triggered_actions = array(); @@ -71,6 +77,9 @@ final class WordPress_Stubs { public static bool $can_manage_options = true; + /** @var array> */ + public static array $user_capabilities = array(); + public static bool $is_rtl = false; public static bool $has_scheduled_action = false; @@ -98,6 +107,8 @@ public static function reset(): void { self::$abilities = array(); self::$ability_categories = array(); self::$scheduled_actions = array(); + self::$single_scheduled_actions = array(); + self::$async_actions = array(); self::$triggered_actions = array(); self::$options = array(); self::$user_meta = array(); @@ -105,6 +116,7 @@ public static function reset(): void { self::$post_meta = array(); self::$next_post_id = 1; self::$can_manage_options = true; + self::$user_capabilities = array(); self::$is_rtl = false; self::$has_scheduled_action = false; self::$current_user_id = 1; @@ -218,6 +230,13 @@ function sanitize_title( string $title ): string { } } + if ( ! function_exists( 'sanitize_key' ) ) { + function sanitize_key( string $key ): string { + $key = strtolower( trim( $key ) ); + return (string) preg_replace( '/[^a-z0-9_\-]/', '', $key ); + } + } + if ( ! function_exists( 'wp_json_encode' ) ) { function wp_json_encode( $value, int $flags = 0, int $depth = 512 ) { return json_encode( $value, $flags, $depth ); @@ -315,6 +334,11 @@ function wp_localize_script( $handle, $object_name, $l10n ): bool { if ( ! function_exists( 'current_user_can' ) ) { function current_user_can( string $capability ): bool { + $current_user_id = WordPress_Stubs::$current_user_id; + if ( isset( WordPress_Stubs::$user_capabilities[ $current_user_id ][ $capability ] ) ) { + return true === WordPress_Stubs::$user_capabilities[ $current_user_id ][ $capability ]; + } + if ( 'manage_options' === $capability ) { return WordPress_Stubs::$can_manage_options; } @@ -325,7 +349,10 @@ function current_user_can( string $capability ): bool { if ( ! function_exists( 'user_can' ) ) { function user_can( int $user_id, string $capability ): bool { - unset( $user_id ); + if ( isset( WordPress_Stubs::$user_capabilities[ $user_id ][ $capability ] ) ) { + return true === WordPress_Stubs::$user_capabilities[ $user_id ][ $capability ]; + } + return current_user_can( $capability ); } } @@ -641,6 +668,31 @@ function as_schedule_recurring_action( int $timestamp, int $interval_in_seconds, } } + if ( ! function_exists( 'as_schedule_single_action' ) ) { + function as_schedule_single_action( int $timestamp, string $hook, array $args = array(), string $group = '', bool $unique = false, int $priority = 10 ): int { + unset( $unique, $priority ); + WordPress_Stubs::$single_scheduled_actions[] = array( + 'timestamp' => $timestamp, + 'hook' => $hook, + 'args' => $args, + 'group' => $group, + ); + return 1; + } + } + + if ( ! function_exists( 'as_enqueue_async_action' ) ) { + function as_enqueue_async_action( string $hook, array $args = array(), string $group = '', bool $unique = false, int $priority = 10 ): int { + unset( $unique, $priority ); + WordPress_Stubs::$async_actions[] = array( + 'hook' => $hook, + 'args' => $args, + 'group' => $group, + ); + return 1; + } + } + if ( ! function_exists( 'is_rtl' ) ) { function is_rtl(): bool { return WordPress_Stubs::$is_rtl; diff --git a/tests/Unit/AbilitiesHelperTest.php b/tests/Unit/AbilitiesHelperTest.php index 8cb8569..0d4f253 100644 --- a/tests/Unit/AbilitiesHelperTest.php +++ b/tests/Unit/AbilitiesHelperTest.php @@ -154,6 +154,60 @@ public function test_runtime_policy_can_enforce_file_delete_gate_for_spawned_age $this->assertSame( 'spawned_agent', $result['policy']['trigger_type'] ); } + public function test_policy_violation_degrade_mode_returns_successful_degraded_result(): void { + $result = Abilities_Helper::get_instance()->execute_tool_call( + 'file_delete', + [ + 'path' => 'notes.md', + ], + [ + 'requesting_user_id' => 1, + 'execution_user_id' => 1, + 'runtime_policy' => [ + 'trigger_type' => 'spawned_agent', + 'policy_profile' => 'default', + 'allow_tools' => true, + 'allow_destructive_tools' => true, + 'require_confirmation_for_destructive' => true, + 'allow_file_delete' => false, + 'on_policy_violation' => 'degrade', + ], + ] + ); + + $this->assertTrue( $result['success'] ); + $this->assertSame( true, $result['degraded'] ); + $this->assertArrayNotHasKey( 'error', $result ); + $this->assertSame( 'degrade', $result['policy']['on_violation'] ); + $this->assertSame( 'deny_file_delete', $result['policy']['decision'] ); + } + + public function test_policy_violation_fail_mode_returns_error_payload(): void { + $result = Abilities_Helper::get_instance()->execute_tool_call( + 'file_delete', + [ + 'path' => 'notes.md', + ], + [ + 'requesting_user_id' => 1, + 'execution_user_id' => 1, + 'runtime_policy' => [ + 'trigger_type' => 'spawned_agent', + 'policy_profile' => 'default', + 'allow_tools' => true, + 'allow_destructive_tools' => true, + 'require_confirmation_for_destructive' => true, + 'allow_file_delete' => false, + 'on_policy_violation' => 'fail', + ], + ] + ); + + $this->assertFalse( $result['success'] ); + $this->assertSame( 'clawpress_policy_file_delete_denied_fail', $result['error']['code'] ); + $this->assertSame( 'fail', $result['policy']['on_violation'] ); + } + public function test_destructive_confirmation_token_must_be_allowlisted_by_execution_context(): void { $initial = Abilities_Helper::get_instance()->execute_tool_call( 'file_delete', diff --git a/tests/Unit/ActionLogHelperTest.php b/tests/Unit/ActionLogHelperTest.php index 46dba9e..063711c 100644 --- a/tests/Unit/ActionLogHelperTest.php +++ b/tests/Unit/ActionLogHelperTest.php @@ -101,8 +101,21 @@ public function prepare( string $query, ...$args ): string { $args = $args[0]; } - $this->last_prepare_args = $args; - return $query; + $prepared_query = $query; + $non_identifier_arguments = []; + + foreach ( $args as $argument ) { + if ( false !== strpos( $prepared_query, '%i' ) ) { + $identifier = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $argument ); + $prepared_query = preg_replace( '/%i/', (string) $identifier, $prepared_query, 1 ) ?? $prepared_query; + continue; + } + + $non_identifier_arguments[] = $argument; + } + + $this->last_prepare_args = $non_identifier_arguments; + return $prepared_query; } /** diff --git a/tests/Unit/AgentEventHelperTest.php b/tests/Unit/AgentEventHelperTest.php index ac81c71..185c3e9 100644 --- a/tests/Unit/AgentEventHelperTest.php +++ b/tests/Unit/AgentEventHelperTest.php @@ -106,8 +106,21 @@ public function prepare( string $query, ...$args ): string { $args = $args[0]; } - $this->last_prepare_args = $args; - return $query; + $prepared_query = $query; + $non_identifier_arguments = []; + + foreach ( $args as $argument ) { + if ( false !== strpos( $prepared_query, '%i' ) ) { + $identifier = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $argument ); + $prepared_query = preg_replace( '/%i/', (string) $identifier, $prepared_query, 1 ) ?? $prepared_query; + continue; + } + + $non_identifier_arguments[] = $argument; + } + + $this->last_prepare_args = $non_identifier_arguments; + return $prepared_query; } /** diff --git a/tests/Unit/AgentLoopHelperTest.php b/tests/Unit/AgentLoopHelperTest.php new file mode 100644 index 0000000..9c7cdc5 --- /dev/null +++ b/tests/Unit/AgentLoopHelperTest.php @@ -0,0 +1,185 @@ + */ + public array $captured = []; + + /** + * @param array $context Runtime context. + * @return array + */ + public function __invoke( array $context, string $provider, string $model ): array { + $this->captured = [ + 'context' => $context, + 'provider' => $provider, + 'model' => $model, + ]; + + return [ + 'reply' => 'Invokable generator reply.', + ]; + } + }; + + $result = Agent_Loop_Helper::get_instance()->run_turn( + [ + 'message' => 'Hello loop', + 'trigger' => 'chat', + 'transport_mode' => 'polling', + 'requesting_user_id' => 1, + 'execution_user_id' => 1, + 'provider_model_resolver' => static fn( array $settings ): array => [ + 'provider' => 'openai', + 'model' => 'gpt-4.1-mini', + ], + 'online_reply_generator' => $invokable, + ] + ); + + $this->assertSame( 'success', $result['status'] ); + $this->assertSame( 'online', $result['mode'] ); + $this->assertSame( 'Invokable generator reply.', $result['assistant_text'] ); + $this->assertSame( 'openai', $invokable->captured['provider'] ); + $this->assertSame( 'gpt-4.1-mini', $invokable->captured['model'] ); + $this->assertSame( 'Hello loop', $invokable->captured['context']['message'] ); + } + + public function test_run_turn_returns_offline_mode_when_provider_is_missing(): void { + $result = Agent_Loop_Helper::get_instance()->run_turn( + [ + 'message' => 'Offline check', + 'provider_model_resolver' => static fn( array $settings ): array => [ + 'provider' => '', + 'model' => '', + ], + ] + ); + + $this->assertSame( 'success', $result['status'] ); + $this->assertSame( 'offline', $result['mode'] ); + $this->assertSame( '', $result['assistant_text'] ); + } + + public function test_run_turn_streaming_mode_uses_polling_transport_events_cursor(): void { + $result = Agent_Loop_Helper::get_instance()->run_turn( + [ + 'run_id' => 55, + 'session_id' => 77, + 'message' => 'Streaming mode', + 'transport_mode' => 'streaming', + 'provider_model_resolver' => static fn( array $settings ): array => [ + 'provider' => 'openai', + 'model' => 'gpt-4.1-mini', + ], + 'online_reply_generator' => static function (): array { + return [ + 'status' => 'success', + 'next_action' => 'stop', + 'reply' => 'Streaming fallback reply.', + ]; + }, + ] + ); + + $this->assertSame( 'success', $result['status'] ); + $this->assertSame( 'Streaming fallback reply.', $result['assistant_text'] ); + $this->assertArrayHasKey( 'events_cursor', $result ); + $this->assertGreaterThan( 0, (int) $result['events_cursor'] ); + $this->assertNotEmpty( $GLOBALS['wpdb']->events ); + } + + public function test_run_turn_fills_terminal_empty_assistant_text_with_friendly_message(): void { + $result = Agent_Loop_Helper::get_instance()->run_turn( + [ + 'message' => 'Summarize files', + 'trigger' => 'chat', + 'transport_mode' => 'polling', + 'requesting_user_id' => 1, + 'execution_user_id' => 1, + 'provider_model_resolver' => static fn( array $settings ): array => [ + 'provider' => 'openai', + 'model' => 'gpt-4.1-mini', + ], + 'online_reply_generator' => static function (): array { + return [ + 'status' => 'success', + 'next_action' => 'stop', + 'reply' => '', + 'tool_calls' => [ + [ + 'name' => 'file_read', + 'status' => 'success', + 'round' => 1, + 'sequence' => 1, + ], + ], + ]; + }, + ] + ); + + $this->assertSame( 'success', $result['status'] ); + $this->assertSame( + 'I finished the background steps, but I did not receive a final text response. Please tell me to continue and I will pick up from here.', + $result['assistant_text'] + ); + } + + public function test_run_slice_returns_in_progress_with_resume_cursor(): void { + $result = Agent_Loop_Helper::get_instance()->run_slice( + [ + 'run_id' => 100, + 'session_id' => 200, + 'message' => 'Slice budget test', + 'transport_mode' => 'polling', + 'slice_budget_ms' => 1, + 'max_steps_per_slice' => 1, + 'provider_model_resolver' => static fn( array $settings ): array => [ + 'provider' => 'openai', + 'model' => 'gpt-4.1-mini', + ], + 'online_reply_generator' => static function (): array { + return [ + 'status' => 'in_progress', + 'next_action' => 'continue_later', + 'reply' => 'Paused slice', + 'resume_cursor' => [ + 'version' => 1, + 'round' => 1, + ], + ]; + }, + ] + ); + + $this->assertSame( 'in_progress', $result['status'] ); + $this->assertSame( 'continue_later', $result['next_action'] ); + $this->assertIsArray( $result['resume_cursor'] ); + $this->assertSame( 1, $result['resume_cursor']['version'] ); + } +} diff --git a/tests/Unit/AgentRunControllerTest.php b/tests/Unit/AgentRunControllerTest.php new file mode 100644 index 0000000..eca6c78 --- /dev/null +++ b/tests/Unit/AgentRunControllerTest.php @@ -0,0 +1,235 @@ +create_run( + new \WP_REST_Request( + [ + 'trigger' => 'chat', + 'message' => 'Process this run', + 'transport_mode' => 'polling', + 'max_attempts' => 6, + 'slice_budget_ms' => 2000, + 'max_steps_per_slice' => 2, + ] + ) + ); + + $data = $response->get_data(); + $this->assertSame( 201, $response->get_status() ); + $this->assertGreaterThan( 0, (int) $data['session_id'] ); + $this->assertGreaterThan( 0, (int) $data['run_id'] ); + $this->assertSame( 'queued', $data['status'] ); + $this->assertCount( 1, WordPress_Stubs::$async_actions ); + $this->assertSame( Agent_Runner::RUN_SLICE_ACTION_HOOK, WordPress_Stubs::$async_actions[0]['hook'] ); + } + + public function test_create_run_uses_configured_agent_user_for_execution_identity(): void { + update_option( + 'clawpress_settings', + [ + 'agent_user_id' => 9, + ] + ); + + $controller = new Agent_Run_Controller(); + $response = $controller->create_run( + new \WP_REST_Request( + [ + 'message' => 'Execution user test', + ] + ) + ); + + $data = $response->get_data(); + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 9, (int) $GLOBALS['wpdb']->sessions[ (int) $data['session_id'] ]['execution_user_id'] ); + } + + public function test_enqueue_run_rejects_non_terminal_status(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session(); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); + $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-running', 120 ); + $this->assertTrue( $claim['claimed'] ); + + $controller = new Agent_Run_Controller(); + $response = $controller->enqueue_run( new \WP_REST_Request( [ 'run_id' => $run_id ] ) ); + + $data = $response->get_data(); + $this->assertSame( 409, $response->get_status() ); + $this->assertSame( 'Run is not in a retryable state.', $data['error'] ); + $this->assertCount( 0, WordPress_Stubs::$async_actions ); + } + + public function test_get_run_events_returns_incremental_cursor(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session(); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); + $event_a = Agent_Event_Helper::get_instance()->emit( + 'agent.test.a', + [ + 'run_id' => $run_id, + 'payload' => [ 'step' => 1 ], + ] + ); + $event_b = Agent_Event_Helper::get_instance()->emit( + 'agent.test.b', + [ + 'run_id' => $run_id, + 'payload' => [ 'step' => 2 ], + ] + ); + + $controller = new Agent_Run_Controller(); + $response = $controller->get_run_events( + new \WP_REST_Request( + [ + 'run_id' => $run_id, + 'after' => 0, + 'limit' => 50, + ] + ) + ); + + $data = $response->get_data(); + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $run_id, $data['run_id'] ); + $this->assertCount( 2, $data['events'] ); + $this->assertSame( $event_a, $data['events'][0]['event_id'] ); + $this->assertSame( $event_b, $data['next_cursor'] ); + } + + public function test_create_run_rejects_unknown_session_id(): void { + $controller = new Agent_Run_Controller(); + $response = $controller->create_run( + new \WP_REST_Request( + [ + 'session_id' => 9999, + 'message' => 'Invalid session test', + ] + ) + ); + + $data = $response->get_data(); + $this->assertSame( 404, $response->get_status() ); + $this->assertSame( 'Session not found.', $data['error'] ); + } + + public function test_create_run_reuses_idempotency_key_for_same_session(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session( + [ + 'requesting_user_id' => 1, + 'execution_user_id' => 1, + ] + ); + $controller = new Agent_Run_Controller(); + $first_reply = $controller->create_run( + new \WP_REST_Request( + [ + 'session_id' => $session_id, + 'message' => 'Idempotency request', + 'idempotency_key' => 'same-key-123', + 'transport_mode' => 'polling', + 'max_attempts' => 4, + ] + ) + ); + $second_reply = $controller->create_run( + new \WP_REST_Request( + [ + 'session_id' => $session_id, + 'message' => 'Idempotency request duplicate', + 'idempotency_key' => 'same-key-123', + ] + ) + ); + + $first_data = $first_reply->get_data(); + $second_data = $second_reply->get_data(); + + $this->assertSame( 201, $first_reply->get_status() ); + $this->assertSame( 200, $second_reply->get_status() ); + $this->assertSame( $first_data['run_id'], $second_data['run_id'] ); + $this->assertCount( 1, $GLOBALS['wpdb']->runs ); + $this->assertCount( 1, WordPress_Stubs::$async_actions ); + } + + public function test_controller_exposes_spawn_endpoint_method(): void { + $this->assertTrue( method_exists( Agent_Run_Controller::class, 'spawn_agent' ) ); + } + + public function test_spawn_agent_creates_spawned_session_and_run(): void { + $controller = new Agent_Run_Controller(); + $response = $controller->spawn_agent( + new \WP_REST_Request( + [ + 'message' => 'Run spawned workflow', + 'transport_mode' => 'polling', + 'slice_budget_ms' => 1200, + 'max_steps_per_slice' => 2, + ] + ) + ); + + $data = $response->get_data(); + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'spawned_agent', $data['trigger'] ); + $this->assertSame( 'queued', $data['status'] ); + $this->assertGreaterThan( 0, (int) $data['session_id'] ); + $this->assertGreaterThan( 0, (int) $data['run_id'] ); + $this->assertCount( 1, WordPress_Stubs::$async_actions ); + $this->assertSame( Agent_Runner::RUN_SLICE_ACTION_HOOK, WordPress_Stubs::$async_actions[0]['hook'] ); + $this->assertSame( 'spawned_agent', $GLOBALS['wpdb']->sessions[ (int) $data['session_id'] ]['trigger_type'] ); + $this->assertSame( 'spawned_agent', $GLOBALS['wpdb']->runs[ (int) $data['run_id'] ]['trigger_type'] ); + } + + public function test_spawn_agent_uses_configured_agent_user_for_execution_identity(): void { + update_option( + 'clawpress_settings', + [ + 'agent_user_id' => 11, + ] + ); + + $controller = new Agent_Run_Controller(); + $response = $controller->spawn_agent( + new \WP_REST_Request( + [ + 'message' => 'Execution identity for spawn', + ] + ) + ); + + $data = $response->get_data(); + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 11, (int) $GLOBALS['wpdb']->sessions[ (int) $data['session_id'] ]['execution_user_id'] ); + } +} diff --git a/tests/Unit/AgentRunHelperTest.php b/tests/Unit/AgentRunHelperTest.php index 8cb22b4..4db770f 100644 --- a/tests/Unit/AgentRunHelperTest.php +++ b/tests/Unit/AgentRunHelperTest.php @@ -31,210 +31,14 @@ function dbDelta( string $queries ): array { use ClawPress\Helpers\Agent_Run_Helper; use ClawPress\Helpers\Agent_Session_Helper; use ClawPress\Plugin; +use ClawPress\Tests\Support\Agent_Runtime_Wpdb; use ClawPress\Tests\Support\TestCase; -/** - * Minimal in-memory wpdb stub for run/session store tests. - */ -final class AgentRunHelperTestWpdb { - public string $prefix = 'wp_'; - - public int $insert_id = 0; - - /** @var array> */ - public array $sessions = []; - - /** @var array> */ - public array $runs = []; - - /** @var array */ - public array $last_prepare_args = []; - - public bool $fail_session_update = false; - - private bool $in_transaction = false; - - /** @var array{sessions:array>,runs:array>,insert_id:int}|null */ - private ?array $transaction_snapshot = null; - - public function get_charset_collate(): string { - return 'DEFAULT CHARSET=utf8mb4'; - } - - /** - * @param string $table Table name. - * @param array $data Data payload. - * @param array $format Format list. - */ - public function insert( string $table, array $data, array $format ) { - unset( $format ); - - $this->insert_id++; - $data['id'] = $this->insert_id; - - if ( false !== strpos( $table, 'agent_sessions' ) ) { - $this->sessions[ $this->insert_id ] = $data; - return 1; - } - - if ( false !== strpos( $table, 'agent_runs' ) ) { - $this->runs[ $this->insert_id ] = $data; - return 1; - } - - return false; - } - - /** - * @param string $table Table name. - * @param array $data Data payload. - * @param array $where Where payload. - * @param array $format Formats. - * @param array $where_format Where formats. - */ - public function update( string $table, array $data, array $where, ?array $format = null, ?array $where_format = null ) { - unset( $format, $where_format ); - - $target = false !== strpos( $table, 'agent_sessions' ) ? 'sessions' : ( false !== strpos( $table, 'agent_runs' ) ? 'runs' : '' ); - if ( '' === $target ) { - return false; - } - if ( $this->fail_session_update && 'sessions' === $target ) { - return false; - } - - $rows = $this->{$target}; - $updated = 0; - - foreach ( $rows as $id => $row ) { - $matches = true; - foreach ( $where as $key => $value ) { - $current = $row[ $key ] ?? null; - if ( (string) $current !== (string) $value ) { - $matches = false; - break; - } - } - - if ( ! $matches ) { - continue; - } - - $this->{$target}[ $id ] = array_merge( $row, $data ); - ++$updated; - } - - return $updated; - } - - public function query( string $sql ) { - $sql = strtoupper( trim( $sql ) ); - - if ( 'START TRANSACTION' === $sql ) { - $this->in_transaction = true; - $this->transaction_snapshot = [ - 'sessions' => $this->sessions, - 'runs' => $this->runs, - 'insert_id' => $this->insert_id, - ]; - return 1; - } - - if ( 'COMMIT' === $sql ) { - $this->in_transaction = false; - $this->transaction_snapshot = null; - return 1; - } - - if ( 'ROLLBACK' === $sql ) { - if ( $this->in_transaction && is_array( $this->transaction_snapshot ) ) { - $this->sessions = $this->transaction_snapshot['sessions']; - $this->runs = $this->transaction_snapshot['runs']; - $this->insert_id = $this->transaction_snapshot['insert_id']; - } - - $this->in_transaction = false; - $this->transaction_snapshot = null; - return 1; - } - - if ( str_starts_with( $sql, 'UPDATE' ) && false !== strpos( $sql, 'AGENT_SESSIONS' ) ) { - if ( $this->fail_session_update ) { - return false; - } - - $session_id = isset( $this->last_prepare_args[5] ) ? (int) $this->last_prepare_args[5] : 0; - if ( $session_id <= 0 || ! isset( $this->sessions[ $session_id ] ) ) { - return 0; - } - - $run_status = isset( $this->last_prepare_args[1] ) ? (string) $this->last_prepare_args[1] : ''; - $failures = (int) ( $this->sessions[ $session_id ]['consecutive_failures'] ?? 0 ); - if ( 'success' === $run_status ) { - $failures = 0; - } else { - ++$failures; - } - - $this->sessions[ $session_id ]['last_run_at_gmt'] = isset( $this->last_prepare_args[0] ) ? (string) $this->last_prepare_args[0] : null; - $this->sessions[ $session_id ]['last_run_status'] = $run_status; - $this->sessions[ $session_id ]['consecutive_failures'] = $failures; - $this->sessions[ $session_id ]['next_run_at_gmt'] = $this->last_prepare_args[3] ?? null; - $this->sessions[ $session_id ]['updated_at_gmt'] = isset( $this->last_prepare_args[4] ) ? (string) $this->last_prepare_args[4] : null; - return 1; - } - - return false; - } - - /** - * @param string $query Query string. - * @param array|mixed ...$args Prepare args. - */ - public function prepare( string $query, ...$args ): string { - if ( 1 === count( $args ) && is_array( $args[0] ) ) { - $args = $args[0]; - } - - $this->last_prepare_args = $args; - return $query; - } - - /** - * @return array|null - */ - public function get_row( string $query, string $output ) { - unset( $output ); - - $id = 0; - if ( preg_match( '/WHERE id =\s*(\d+)/', $query, $matches ) ) { - $id = (int) $matches[1]; - } - if ( $id <= 0 ) { - $id = isset( $this->last_prepare_args[0] ) ? (int) $this->last_prepare_args[0] : 0; - } - - if ( $id <= 0 ) { - return null; - } - - if ( false !== strpos( $query, 'agent_sessions' ) ) { - return $this->sessions[ $id ] ?? null; - } - - if ( false !== strpos( $query, 'agent_runs' ) ) { - return $this->runs[ $id ] ?? null; - } - - return null; - } -} - final class AgentRunHelperTest extends TestCase { protected function setUp(): void { parent::setUp(); $GLOBALS['clawpress_test_dbdelta_queries'] = []; - $GLOBALS['wpdb'] = new AgentRunHelperTestWpdb(); + $GLOBALS['wpdb'] = new Agent_Runtime_Wpdb(); } protected function tearDown(): void { @@ -274,6 +78,17 @@ public function test_claim_collision_fails_for_second_worker(): void { $this->assertSame( 'not_claimable', $second['reason'] ); } + public function test_first_claim_keeps_attempt_at_one(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session(); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); + + $result = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + + $this->assertTrue( $result['claimed'] ); + $this->assertSame( 1, $result['attempt'] ); + $this->assertSame( 1, (int) $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] ); + } + public function test_stale_lock_can_be_reclaimed_and_attempt_increments(): void { $session_id = Agent_Session_Helper::get_instance()->create_session(); $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); @@ -289,6 +104,170 @@ public function test_stale_lock_can_be_reclaimed_and_attempt_increments(): void $this->assertSame( 2, (int) $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] ); } + public function test_claim_paused_continuation_does_not_increment_attempt(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session(); + $run_id = Agent_Run_Helper::get_instance()->create_run( + $session_id, + [ + 'status' => 'paused', + 'attempt' => 1, + 'meta' => [ + 'pause_reason' => 'slice_budget', + ], + ] + ); + + $result = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-continuation', 120 ); + + $this->assertTrue( $result['claimed'] ); + $this->assertSame( 1, $result['attempt'] ); + $this->assertSame( 1, (int) $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] ); + } + + public function test_claim_paused_retry_backoff_increments_attempt(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session(); + $run_id = Agent_Run_Helper::get_instance()->create_run( + $session_id, + [ + 'status' => 'paused', + 'attempt' => 1, + 'meta' => [ + 'pause_reason' => 'retry_backoff', + ], + ] + ); + + $result = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-retry', 120 ); + + $this->assertTrue( $result['claimed'] ); + $this->assertSame( 2, $result['attempt'] ); + $this->assertSame( 2, (int) $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] ); + } + + public function test_claim_session_allows_paused_status(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session( + [ + 'status' => 'paused', + ] + ); + + $claim = Agent_Session_Helper::get_instance()->claim_session( $session_id, 'worker-paused', 120 ); + + $this->assertTrue( $claim['claimed'] ); + $this->assertSame( 'running', $GLOBALS['wpdb']->sessions[ $session_id ]['status'] ); + } + + public function test_pause_run_does_not_increment_session_failures(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session(); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); + $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + + $paused = Agent_Run_Helper::get_instance()->pause_run( + $run_id, + (string) $claim['lock_token'], + [ + 'status' => 'paused', + ] + ); + + $this->assertTrue( $paused ); + $this->assertSame( 'paused', $GLOBALS['wpdb']->sessions[ $session_id ]['last_run_status'] ); + $this->assertSame( 0, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); + } + + public function test_pause_run_clears_lock_metadata_and_persists_retry_count(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session(); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); + $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + + $paused = Agent_Run_Helper::get_instance()->pause_run( + $run_id, + (string) $claim['lock_token'], + [ + 'status' => 'paused', + 'retry_count' => 2, + ] + ); + + $this->assertTrue( $paused ); + $run = Agent_Run_Helper::get_instance()->get_run( $run_id ); + $this->assertSame( 'paused', $run['status'] ); + $this->assertSame( 2, (int) $run['retry_count'] ); + $this->assertNull( $run['lock_token'] ); + $this->assertNull( $run['claimed_by'] ); + $this->assertNull( $run['lock_acquired_at_gmt'] ); + $this->assertNull( $run['lock_expires_at_gmt'] ); + } + + public function test_pause_run_preserves_existing_resume_cursor_and_merges_meta(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session(); + $run_id = Agent_Run_Helper::get_instance()->create_run( + $session_id, + [ + 'meta' => [ + 'message' => 'hello', + 'slice_budget_ms' => 1500, + 'max_steps_per_slice' => 2, + ], + 'resume_cursor' => [ + 'version' => 1, + 'round' => 2, + ], + ] + ); + $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + + $paused = Agent_Run_Helper::get_instance()->pause_run( + $run_id, + (string) $claim['lock_token'], + [ + 'status' => 'paused', + 'retry_count' => 1, + 'meta' => [ + 'pause_reason' => 'retry_backoff', + ], + ] + ); + + $this->assertTrue( $paused ); + $run = Agent_Run_Helper::get_instance()->get_run( $run_id ); + $this->assertSame( 1, (int) $run['retry_count'] ); + $this->assertSame( 'hello', $run['meta']['message'] ?? '' ); + $this->assertSame( 1500, (int) ( $run['meta']['slice_budget_ms'] ?? 0 ) ); + $this->assertSame( 2, (int) ( $run['meta']['max_steps_per_slice'] ?? 0 ) ); + $this->assertSame( 'retry_backoff', $run['meta']['pause_reason'] ?? '' ); + $this->assertSame( 1, (int) ( $run['resume_cursor']['version'] ?? 0 ) ); + $this->assertSame( 2, (int) ( $run['resume_cursor']['round'] ?? 0 ) ); + } + + public function test_enqueue_run_rejects_non_terminal_statuses(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session(); + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); + $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + + $this->assertTrue( $claim['claimed'] ); + $this->assertFalse( Agent_Run_Helper::get_instance()->enqueue_run( $run_id ) ); + } + + public function test_requires_confirmation_does_not_increment_session_failures(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session(); + $this->assertTrue( Agent_Session_Helper::get_instance()->apply_run_completion( $session_id, 'failed', null ) ); + $this->assertSame( 1, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); + + $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); + $claim = Agent_Run_Helper::get_instance()->claim_run( $run_id, 'worker-a', 120 ); + $this->assertTrue( $claim['claimed'] ); + + $completed = Agent_Run_Helper::get_instance()->complete_run( + $run_id, + (string) $claim['lock_token'], + 'requires_confirmation' + ); + + $this->assertTrue( $completed ); + $this->assertSame( 0, (int) $GLOBALS['wpdb']->sessions[ $session_id ]['consecutive_failures'] ); + } + public function test_complete_run_clears_lock_and_updates_session_state(): void { $session_id = Agent_Session_Helper::get_instance()->create_session(); $run_id = Agent_Run_Helper::get_instance()->create_run( $session_id ); @@ -357,5 +336,34 @@ public function test_helpers_do_not_expose_schema_methods(): void { $this->assertFalse( method_exists( Agent_Session_Helper::class, 'create_table' ) ); $this->assertFalse( method_exists( Agent_Session_Helper::class, 'get_table_name' ) ); } + + public function test_create_run_recovers_from_duplicate_insert_race_with_idempotency_key(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session(); + $wpdb = $GLOBALS['wpdb']; + $this->assertInstanceOf( Agent_Runtime_Wpdb::class, $wpdb ); + $wpdb->simulate_idempotency_race = true; + + $run_id = Agent_Run_Helper::get_instance()->create_run( + $session_id, + [ + 'idempotency_key' => 'race-key-123', + 'meta' => [ + 'message' => 'race test', + ], + ] + ); + + $this->assertGreaterThan( 0, $run_id ); + $this->assertCount( 1, $wpdb->runs ); + $this->assertSame( 'race-key-123', (string) $wpdb->runs[ $run_id ]['idempotency_key'] ); + + $run_id_2 = Agent_Run_Helper::get_instance()->create_run( + $session_id, + [ + 'idempotency_key' => 'race-key-123', + ] + ); + $this->assertSame( $run_id, $run_id_2 ); + } } } diff --git a/tests/Unit/AgentRunnerTest.php b/tests/Unit/AgentRunnerTest.php new file mode 100644 index 0000000..59c1d55 --- /dev/null +++ b/tests/Unit/AgentRunnerTest.php @@ -0,0 +1,200 @@ +create_session( + [ + 'status' => 'paused', + 'requesting_user_id' => 1, + 'execution_user_id' => 1, + ] + ); + $run_id = Agent_Run_Helper::get_instance()->create_run( + $session_id, + [ + 'status' => 'queued', + 'meta' => [ + 'message' => 'Execute queued run.', + ], + ] + ); + + $runner = new Agent_Runner(); + $runner->run_scheduled_tasks(); + + $run_row = Agent_Run_Helper::get_instance()->get_run( $run_id ); + $this->assertSame( 'done', $run_row['status'] ); + $this->assertNull( $run_row['lock_token'] ); + $this->assertSame( 'idle', $GLOBALS['wpdb']->sessions[ $session_id ]['status'] ); + $this->assertSame( 'done', $GLOBALS['wpdb']->sessions[ $session_id ]['last_run_status'] ); + $history = get_option( 'clawpress_chat_history_1', [] ); + $this->assertIsArray( $history ); + $this->assertNotEmpty( $history ); + $last_history_item = $history[ count( $history ) - 1 ]; + $this->assertIsArray( $last_history_item ); + $this->assertSame( 'assistant', $last_history_item['role'] ?? '' ); + $this->assertNotSame( '', trim( (string) ( $last_history_item['content'] ?? '' ) ) ); + } + + public function test_enqueue_run_slice_uses_async_and_delayed_single_actions(): void { + $runner = new Agent_Runner(); + + $runner->enqueue_run_slice( 22, 0 ); + $runner->enqueue_run_slice( 23, 15 ); + + $this->assertCount( 1, WordPress_Stubs::$async_actions ); + $this->assertSame( Agent_Runner::RUN_SLICE_ACTION_HOOK, WordPress_Stubs::$async_actions[0]['hook'] ); + $this->assertSame( 22, WordPress_Stubs::$async_actions[0]['args']['run_id'] ); + $this->assertCount( 1, WordPress_Stubs::$single_scheduled_actions ); + $this->assertSame( Agent_Runner::RUN_SLICE_ACTION_HOOK, WordPress_Stubs::$single_scheduled_actions[0]['hook'] ); + $this->assertSame( 23, WordPress_Stubs::$single_scheduled_actions[0]['args']['run_id'] ); + } + + public function test_runner_marks_run_error_when_session_is_missing(): void { + $run_id = Agent_Run_Helper::get_instance()->create_run( + 9999, + [ + 'status' => 'queued', + 'meta' => [ + 'message' => 'Missing session', + ], + ] + ); + + $runner = new Agent_Runner(); + $runner->run_slice_action( $run_id ); + + $run = Agent_Run_Helper::get_instance()->get_run( $run_id ); + $this->assertSame( 'error', $run['status'] ); + $this->assertSame( 'session_missing', $run['error_code'] ); + } + + public function test_runner_pauses_run_when_session_cannot_be_claimed(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session( + [ + 'status' => 'running', + ] + ); + $GLOBALS['wpdb']->sessions[ $session_id ]['lease_expires_at_gmt'] = gmdate( 'Y-m-d H:i:s', time() + 300 ); + + $run_id = Agent_Run_Helper::get_instance()->create_run( + $session_id, + [ + 'status' => 'queued', + 'meta' => [ + 'message' => 'Busy session', + ], + ] + ); + + $runner = new Agent_Runner(); + $runner->run_slice_action( $run_id ); + + $run = Agent_Run_Helper::get_instance()->get_run( $run_id ); + $this->assertSame( 'paused', $run['status'] ); + $this->assertNotNull( $run['next_retry_at_gmt'] ?? null ); + $this->assertSame( 'session_not_claimable', $run['meta']['reason'] ?? null ); + $this->assertSame( 'session_not_claimable', $run['meta']['pause_reason'] ?? null ); + $this->assertCount( 1, WordPress_Stubs::$single_scheduled_actions ); + $this->assertSame( $run_id, WordPress_Stubs::$single_scheduled_actions[0]['args']['run_id'] ); + } + + public function test_runner_reclaims_stale_running_run_from_heartbeat_scan(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session( + [ + 'status' => 'paused', + 'requesting_user_id' => 1, + 'execution_user_id' => 1, + ] + ); + $run_id = Agent_Run_Helper::get_instance()->create_run( + $session_id, + [ + 'status' => 'queued', + 'meta' => [ + 'message' => 'Reclaim stale run', + ], + ] + ); + + $GLOBALS['wpdb']->runs[ $run_id ]['status'] = 'running'; + $GLOBALS['wpdb']->runs[ $run_id ]['attempt'] = 1; + $GLOBALS['wpdb']->runs[ $run_id ]['lock_token'] = 'stale-lock-token'; + $GLOBALS['wpdb']->runs[ $run_id ]['lock_expires_at_gmt'] = gmdate( 'Y-m-d H:i:s', time() - 300 ); + + $runner = new Agent_Runner(); + $runner->run_scheduled_tasks(); + + $run_row = Agent_Run_Helper::get_instance()->get_run( $run_id ); + $this->assertSame( 'done', $run_row['status'] ); + $this->assertSame( 2, (int) $run_row['attempt'] ); + $this->assertNull( $run_row['lock_token'] ); + } + + public function test_retry_backoff_calculates_from_retry_count(): void { + $runner = new Agent_Runner(); + $reflection = new \ReflectionClass( Agent_Runner::class ); + $method = $reflection->getMethod( 'calculate_retry_backoff' ); + $method->setAccessible( true ); + + $this->assertSame( 15, $method->invoke( $runner, 1 ) ); + $this->assertSame( 30, $method->invoke( $runner, 2 ) ); + $this->assertSame( 60, $method->invoke( $runner, 3 ) ); + } + + public function test_runner_times_out_run_when_max_wall_time_is_exceeded(): void { + $session_id = Agent_Session_Helper::get_instance()->create_session( + [ + 'status' => 'paused', + 'requesting_user_id' => 1, + 'execution_user_id' => 1, + ] + ); + $run_id = Agent_Run_Helper::get_instance()->create_run( + $session_id, + [ + 'status' => 'queued', + 'trigger_type' => 'heartbeat', + 'meta' => [ + 'message' => 'Long running job', + ], + ] + ); + + $GLOBALS['wpdb']->runs[ $run_id ]['started_at_gmt'] = gmdate( 'Y-m-d H:i:s', time() - 300 ); + + $runner = new Agent_Runner(); + $runner->run_scheduled_tasks(); + + $run = Agent_Run_Helper::get_instance()->get_run( $run_id ); + $this->assertSame( 'timeout', $run['status'] ); + $this->assertSame( 'wall_time_exceeded', $run['error_code'] ); + $this->assertSame( 'timeout', $GLOBALS['wpdb']->sessions[ $session_id ]['last_run_status'] ); + } +} diff --git a/tests/Unit/ChatHelperTest.php b/tests/Unit/ChatHelperTest.php index 17f8440..c19c052 100644 --- a/tests/Unit/ChatHelperTest.php +++ b/tests/Unit/ChatHelperTest.php @@ -11,7 +11,9 @@ use ClawPress\Helpers\Chat_Helper; use ClawPress\Helpers\Memory_Helper; +use ClawPress\Tests\Support\Agent_Runtime_Wpdb; use ClawPress\Tests\Support\TestCase; +use ClawPress\Tests\Support\WordPress_Stubs; final class ChatHelperTest extends TestCase { public function test_generate_ai_reply_injects_context_into_online_generator(): void { @@ -308,4 +310,51 @@ static function (): array { $this->assertSame( 'requires_confirmation', $payload['tool_calls'][1]['status'] ); $this->assertSame( true, $payload['tool_calls'][1]['requires_confirmation'] ); } -} + + public function test_generate_ai_reply_returns_in_progress_with_run_metadata_when_slice_pauses(): void { + $GLOBALS['wpdb'] = new Agent_Runtime_Wpdb(); + try { + $chat_helper = Chat_Helper::create_for_testing( + null, + static function (): array { + return [ + 'status' => 'in_progress', + 'next_action' => 'continue_later', + 'reply' => 'Working on it...', + 'resume_cursor' => [ + 'version' => 1, + 'round' => 2, + ], + 'events_cursor' => 55, + 'tool_calls' => [ + [ + 'name' => 'file_read', + 'status' => 'success', + ], + ], + ]; + }, + static fn( array $settings ): array => [ + 'provider' => 'openai', + 'model' => 'gpt-4.1-mini', + ] + ); + + $payload = $chat_helper->generate_ai_reply( 'Long running request' ); + + $this->assertSame( 'in_progress', $payload['mode'] ); + $this->assertSame( 'in_progress', $payload['status'] ); + $this->assertGreaterThan( 0, (int) $payload['events_cursor'] ); + $this->assertGreaterThan( 0, (int) $payload['run_id'] ); + $this->assertGreaterThan( 0, (int) $payload['session_id'] ); + $this->assertCount( 1, $GLOBALS['wpdb']->runs ); + $this->assertCount( 1, WordPress_Stubs::$async_actions ); + $this->assertNotEmpty( $GLOBALS['wpdb']->events ); + $events = array_values( $GLOBALS['wpdb']->events ); + $this->assertSame( (int) $payload['run_id'], (int) $events[0]['run_id'] ); + $this->assertSame( (int) $payload['session_id'], (int) $events[0]['session_id'] ); + } finally { + unset( $GLOBALS['wpdb'] ); + } + } + } diff --git a/tests/Unit/ContextHelperTest.php b/tests/Unit/ContextHelperTest.php index 93add51..176d9d5 100644 --- a/tests/Unit/ContextHelperTest.php +++ b/tests/Unit/ContextHelperTest.php @@ -97,6 +97,36 @@ public function test_build_model_context_converts_history_to_model_messages(): v $this->assertInstanceOf( FunctionDeclaration::class, $context['tool_declarations'][0] ); } + public function test_build_model_context_trims_leading_non_user_messages(): void { + update_option( 'clawpress_chat_history_1', [ + [ + 'id' => 'msg-1', + 'role' => 'assistant', + 'content' => 'Interim status', + 'createdAt' => 1, + ], + [ + 'id' => 'msg-2', + 'role' => 'user', + 'content' => 'Real request', + 'createdAt' => 2, + ], + [ + 'id' => 'msg-3', + 'role' => 'assistant', + 'content' => 'Acknowledged', + 'createdAt' => 3, + ], + ] ); + + $helper = Context_Helper::get_instance(); + $context = $helper->build_model_context( 'Continue' ); + + $this->assertCount( 2, $context['history_messages'] ); + $this->assertSame( 'user', $context['history_messages'][0]->getRole()->value ); + $this->assertSame( 'model', $context['history_messages'][1]->getRole()->value ); + } + public function test_build_messages_appends_bootstrap_requirement_before_first_assistant_response(): void { update_option( 'clawpress_settings', diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index a4c0a36..c2a28fa 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -29,7 +29,7 @@ public function test_get_instance_wires_all_module_hooks_once(): void { $hooks = array_column( WordPress_Stubs::$actions, 'hook' ); - $this->assertCount( 16, WordPress_Stubs::$actions ); + $this->assertCount( 18, WordPress_Stubs::$actions ); $this->assertContains( 'init', $hooks ); $this->assertContains( 'use_block_editor_for_post_type', $hooks ); $this->assertContains( 'rest_api_init', $hooks ); @@ -42,6 +42,8 @@ public function test_get_instance_wires_all_module_hooks_once(): void { $this->assertContains( 'action_scheduler_init', $hooks ); $this->assertContains( 'action_scheduler_ensure_recurring_actions', $hooks ); $this->assertContains( 'clawpress_heartbeat_tick', $hooks ); + $this->assertContains( 'clawpress_run_scheduled_tasks', $hooks ); + $this->assertContains( 'clawpress_agent_run_slice', $hooks ); } public function test_plugin_boot_admin_menu_hook_registers_expected_menu_items(): void { diff --git a/tests/Unit/PolicyHelperTest.php b/tests/Unit/PolicyHelperTest.php index dc0c1f5..b948eed 100644 --- a/tests/Unit/PolicyHelperTest.php +++ b/tests/Unit/PolicyHelperTest.php @@ -20,8 +20,8 @@ public function test_chat_trigger_uses_default_policy_contract(): void { $this->assertTrue( $policy['allow_tools'] ); $this->assertTrue( $policy['allow_destructive_tools'] ); $this->assertTrue( $policy['require_confirmation_for_destructive'] ); - $this->assertSame( 4, $policy['max_tool_rounds'] ); - $this->assertSame( 6, $policy['max_tool_calls_per_round'] ); + $this->assertSame( 6, $policy['max_tool_rounds'] ); + $this->assertSame( 8, $policy['max_tool_calls_per_round'] ); } public function test_heartbeat_trigger_resolves_stricter_defaults(): void { diff --git a/tests/Unit/RestApiTest.php b/tests/Unit/RestApiTest.php index dcaa9f7..67fcc78 100644 --- a/tests/Unit/RestApiTest.php +++ b/tests/Unit/RestApiTest.php @@ -37,7 +37,7 @@ static function ( array $route ): string { WordPress_Stubs::$rest_routes ); - $this->assertCount( 7, WordPress_Stubs::$rest_routes ); + $this->assertCount( 12, WordPress_Stubs::$rest_routes ); $this->assertContains( '/settings:GET', $routes ); $this->assertContains( '/settings:POST', $routes ); $this->assertContains( '/status:GET', $routes ); @@ -45,6 +45,11 @@ static function ( array $route ): string { $this->assertContains( '/panel/state:POST', $routes ); $this->assertContains( '/chat/message:POST', $routes ); $this->assertContains( '/chat/history:GET', $routes ); + $this->assertContains( '/agent/runs:POST', $routes ); + $this->assertContains( '/agent/runs/(?P\\d+)/enqueue:POST', $routes ); + $this->assertContains( '/agent/runs/(?P\\d+):GET', $routes ); + $this->assertContains( '/agent/runs/(?P\\d+)/events:GET', $routes ); + $this->assertContains( '/agent/spawn:POST', $routes ); } public function test_settings_routes_use_global_manage_options_permission_callback(): void { @@ -243,12 +248,49 @@ static function ( string $message ): array { 'error' => null, 'context' => null, 'tool_calls' => null, - ), + 'run_id' => null, + 'session_id' => null, + 'events_cursor' => null, + 'status' => null, + ), ), $response->get_data() ); } + public function test_chat_send_message_exposes_in_progress_run_metadata(): void { + $chat_controller = new Chat_Controller( + static function ( string $message ): array { + return array( + 'reply' => 'Still working: ' . $message, + 'mode' => 'in_progress', + 'provider' => 'openai', + 'model' => 'gpt-4.1-mini', + 'run_id' => 42, + 'session_id' => 84, + 'events_cursor' => 123, + 'status' => 'in_progress', + ); + } + ); + + $response = $chat_controller->send_message( + new \WP_REST_Request( + array( + 'message' => 'Continue', + ) + ) + ); + + $data = $response->get_data(); + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'in_progress', $data['meta']['mode'] ); + $this->assertSame( 42, $data['meta']['run_id'] ); + $this->assertSame( 84, $data['meta']['session_id'] ); + $this->assertSame( 123, $data['meta']['events_cursor'] ); + $this->assertSame( 'in_progress', $data['meta']['status'] ); + } + public function test_chat_command_dispatch_skips_reply_generator(): void { $reply_generator_calls = 0; $chat_controller = new Chat_Controller( @@ -611,4 +653,5 @@ public function test_panel_state_update_and_get_round_trip(): void { $get_response->get_data() ); } + } diff --git a/tests/Unit/SecurityTest.php b/tests/Unit/SecurityTest.php new file mode 100644 index 0000000..d3acf42 --- /dev/null +++ b/tests/Unit/SecurityTest.php @@ -0,0 +1,38 @@ +assert_requesting_user_allowed( 1 ); + + $this->assertTrue( true === $result ); + } + + public function test_assert_requesting_user_allowed_rejects_explicit_user_without_capability(): void { + WordPress_Stubs::$current_user_id = 2; + WordPress_Stubs::$can_manage_options = true; + WordPress_Stubs::$user_capabilities[1]['manage_options'] = false; + + $result = Security::get_instance()->assert_requesting_user_allowed( 1 ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'clawpress_requesting_user_forbidden', $result->get_error_code() ); + } +} + diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ad6d3a1..f3e8cfd 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -49,4 +49,5 @@ require_once __DIR__ . '/Support/WordPressStubs.php'; require_once __DIR__ . '/Support/TestCase.php'; +require_once __DIR__ . '/Support/AgentRuntimeWpdb.php'; require_once $plugin_root . '/vendor/autoload.php';