Repository source: docs/plans/agent_reply_one_step_analyze_publish_plan_v1.md
Agent Reply One-Step Analyze/Publish Plan
Goal
Make automatic agent reply publishing happen inside POST /api/analyze_post, while keeping POST /api/generate_agent_reply as an idempotent compatibility and manual retry endpoint.
The intended production behavior when agent replies are enabled is:
A newly created post renders with
data-agent-reply-work="analyze"when no current analysis exists.The browser calls only
POST /api/analyze_post.The backend stores the post analysis.
If the reply gates pass, the backend immediately posts the stored
engagement.suggested_responseas thereply-agent.The response reports both analysis status and agent reply posting status.
This keeps the single model-call design: the analysis prompt drafts the suggested response, and posting consumes that stored suggestion.
Current Behavior
public/assets/post_analysis.jscallsPOST /api/analyze_postforwork === "analyze".If analysis returns
agent_reply_generation_allowed === true, the browser then callsPOST /api/generate_agent_reply.POST /api/generate_agent_replyno longer calls a reply model. It reads the completed analysis and postsengagement.suggested_response.This creates a confusing second browser/API step even though there is not a second model-generation step.
Contract Decisions
These decisions are part of the implementation contract. Do not reinterpret them during implementation.
Configuration
DEDALUS_AGENT_REPLIES_ENABLED=falsedisables automatic browser work rendering for agent replies. Newly created post pages must not renderdata-agent-reply-worksolely to report disabled reply status.A direct
POST /api/analyze_postcall still analyzes the post according to the existing analysis endpoint behavior, but it must not post an agent reply while agent replies are disabled.When a direct
/api/analyze_postcall runs while agent replies are disabled, the analysis response must includeagent_reply_generation_allowed: falseand compact reply status fields withagent_reply_generation_status: "not_recommended"andagent_reply_reason: "config_disabled".POST /api/generate_agent_replymust keep returning its existing compatibility response shape, includingnot_recommended/config_disabledwhen agent replies are disabled.
Generated-Response Rows
For the current target post/content hash, existing generated-response rows have these meanings. Unless a row says otherwise, endpoint behavior in this table assumes agent replies are enabled; disabled configuration follows the Configuration contract above.
| Existing row state | Automatic render work | /api/analyze_post one-step behavior | /api/generate_agent_reply behavior |
|---|---|---|---|
| no row, no current analysis | analyze when agent replies are enabled | analyze, then post if gates pass | analysis_required |
| no row, completed current analysis, gates pass | publish when agent replies are enabled | post from stored suggestion if called directly | post from stored suggestion |
| no row, completed current analysis, gates fail | none | do not post; return compact not_recommended | existing not_recommended |
complete with no agent_post_id | publish when agent replies are enabled | post existing stored suggestion if called directly | post existing stored suggestion |
pending or posting | none | do not start duplicate work; return compact in_progress if reached | existing in_progress |
failed | none | do not auto-retry; return compact failed if reached | existing failed response |
any status with agent_post_id | none | return compact already_posted if reached | existing already_posted |
Automatic browser work must not retry failed, pending, or posting rows. Manual retry semantics remain limited to the existing /api/generate_agent_reply endpoint behavior unless a separate plan changes retry handling; this plan does not add failed-row retry.
Privacy
/api/analyze_postmust never expose generated reply internals such asresponse_text, replyprovider, replyprovider_model,provider_request_id,raw_response,failure_message, orretry_afterthrough the compactagent_reply_*fields.Unauthorized viewers must not receive concrete gate-failure reasons from
/api/analyze_post. When a gate fails andviewer_can_see_analysisis false, useagent_reply_reason: "not_recommended".Authorized viewers may receive concrete gate-failure reasons from
/api/analyze_post, such asrespondability_score_low,response_risk_high,moderation_severity_high,response_not_public, oragent_loop_prevention.Analysis-provider failure details remain governed by the existing analysis response visibility rules. Do not copy analysis-provider failure codes into
agent_reply_failure_code.
Analyze Response Contract
Every successful POST /api/analyze_post response (status: "ok") must include:
existing analysis fields returned today
agent_reply_generation_allowedagent_reply_generation_statusagent_reply_postedagent_reply_post_idagent_reply_post_urlagent_reply_reasonagent_reply_failure_code
agent_reply_generation_allowed means "posting would be allowed by configuration, completed analysis, and gates." It is true only when agent replies are enabled, analysis completed, and agentReplyGateFailure() returns null. It is false for disabled config, incomplete analysis, and gate failures.
The compact fields must always use null for unavailable IDs, URLs, reasons, and failure codes. Do not omit those keys on successful analyze responses.
Existing Test Expectation Changes
Tests that currently expect "analyze now, render
publishon reload" must be updated. After this change, a passing/api/analyze_postcall should already post the reply, so a reload should render nodata-agent-reply-workfor that post.Tests for
/api/generate_agent_replyshould continue to assert the compatibility response shape and idempotency behavior.
Implementation Steps
1. Extract Shared Reply Posting Logic
Move the core body of Application::handleGenerateAgentReply() into this private helper:
private function agentReplyResultForPost(array $post): array
The helper must be an array-returning application helper, not an HTTP responder. It may perform the existing reply-posting side effects, but it must not call sendJson() or terminate request handling. handleGenerateAgentReply() and handleAnalyzePost() should decide how to serialize or embed the returned result.
The helper should preserve current behavior:
check
DEDALUS_AGENT_REPLIES_ENABLEDcompute the current post analysis context and content hash
check existing rows in
SqliteAgentReplyGenerationStorereturn
already_posted,failed, orin_progresswhen appropriaterequire a completed current analysis
apply
agentReplyGateFailure()build the reply generation row from
engagement.suggested_responsereserve posting
ensure the
reply-agentidentitycreate the canonical reply with
LocalWriteService::createReply()mark the generated response row as posted
return the same response shape currently returned by
/api/generate_agent_reply
2. Keep /api/generate_agent_reply
Leave the route in place for compatibility and manual retries.
After method, post_id, and post existence validation, delegate to the shared helper and return its JSON result.
3. Publish During /api/analyze_post
In Application::handleAnalyzePost():
run analysis exactly as today
compute
agent_reply_generation_allowedfrom both the analysis/gate result and reply configuration:trueonly whenDEDALUS_AGENT_REPLIES_ENABLEDis enabled, analysis completed, andagentReplyGateFailure()returnsnullfalsewhen agent replies are disabled, analysis did not complete, or a reply gate fails
build compact skipped reply status fields directly for disabled config, incomplete analysis, and gate-failed cases
when
agent_reply_generation_allowed === true, call the shared reply-posting helper before sending the responsealways embed a compact agent reply result in the analysis JSON response
Add this allowlisted mapper:
private function agentReplySummaryForAnalysisResponse(array $replyResult): array
This mapper should expose only compact posting status fields and must not expose hidden analysis details or generated response text. It should translate the full helper result into agent_reply_* fields.
Suggested response fields:
{
"agent_reply_generation_status": "generated",
"agent_reply_posted": true,
"agent_reply_post_id": "post-id",
"agent_reply_post_url": "/posts/post-id",
"agent_reply_reason": null,
"agent_reply_failure_code": null
}
For successful posting, map:
generation_statustoagent_reply_generation_statuspostedtoagent_reply_postedagent_post_idtoagent_reply_post_idagent_post_urltoagent_reply_post_urlreasontoagent_reply_reasonfailure_codetoagent_reply_failure_code
For skipped cases, the response should still include the same compact fields, for example agent_reply_generation_status: "not_recommended" with agent_reply_reason: "respondability_score_low" or agent_reply_reason: "config_disabled". These skipped summaries should be created in handleAnalyzePost() from the already-known conditions instead of calling the posting helper when posting is not allowed. For failed posting, include agent_reply_generation_status: "failed" and agent_reply_failure_code.
Use an explicit compact status mapping:
| Full or local status | agent_reply_generation_status | agent_reply_posted | agent_reply_post_id / agent_reply_post_url | Reason/failure fields |
|---|---|---|---|---|
| generated | generated | true when helper result has posted === true | from agent_post_id / agent_post_url | nullable |
| already_posted | already_posted | true | from agent_post_id / agent_post_url | nullable |
| not_recommended | not_recommended | false | null | agent_reply_reason from reason |
| analysis_required | analysis_required | false | null | agent_reply_reason from reason; do not set agent_reply_failure_code for analysis-provider failures |
| in_progress | in_progress | false | null | nullable |
| failed | failed | false | null unless a posted ID is already present | agent_reply_failure_code from failure_code |
For /api/analyze_post, the local skipped cases should map as:
DEDALUS_AGENT_REPLIES_ENABLEDdisabled:not_recommended,agent_reply_reason: "config_disabled"analysis missing or not complete after the analysis attempt:
analysis_required, withagent_reply_reasonset tomissing_analysisoranalysis_not_complete. Do not copy the analysis failure code intoagent_reply_failure_code; analysis failure details should remain available only through the existing analysis response fields and existing visibility rules.agentReplyGateFailure()result:not_recommended, withagent_reply_reasonset to the gate failure reason only when the viewer is allowed to see analysis details. For unauthorized viewers, use a genericagent_reply_reason: "not_recommended"so/api/analyze_postdoes not reveal moderation, risk, privacy, or respondability details that are otherwise hidden.
Notes:
Preserve existing analysis visibility rules. Do not expose hidden analysis details to unauthorized viewers beyond the fields already returned today.
It is acceptable to expose reply posting status and reply URL because the canonical reply is public content once posted.
Do not expose
/api/generate_agent_replyfields such asresponse_text,provider, orprovider_modelfrom/api/analyze_postunless they are already part of the normal authorized analysis response.If posting fails, return the analysis response with the agent reply failure fields rather than failing the whole analysis request, unless an existing hard failure already behaves differently.
The compact
agent_reply_failure_codefield in/api/analyze_postis for reply posting/generation failures only, such asanalysis_suggestion_errororposting_error; it should not duplicate analysis-provider failure codes.The mapper must intentionally drop
failure_messageandretry_afterfrom/api/analyze_post, even though/api/generate_agent_replymay continue returning those fields.
4. Update Browser Automation
In public/assets/post_analysis.js:
For
work === "analyze":call only
analyzePost(postId)convert the embedded
agent_reply_*fields into the existing generation-result shape with a small adapter namedagentReplyResultFromAnalysis(analysis)pass the adapted result into the existing feedback renderer
do not call
generateAgentReply(postId)after successful analysis
For
work === "publish":continue calling
generateAgentReply(postId), since this covers pages where analysis already existed before render
Keep the idempotency guard around created post IDs.
The adapter should map:
set
statusto"ok"whenanalysis.status === "ok"and embedded agent reply fields are presentagent_reply_generation_statustogeneration_statusagent_reply_postedtopostedagent_reply_post_idtoagent_post_idagent_reply_post_urltoagent_post_urlagent_reply_reasontoreasonagent_reply_failure_codetofailure_code
Optional cleanup:
Rename local JS helper text from "generation" toward "agent reply" where it improves clarity.
Keep API field names compatible unless there is a clear reason to change them.
5. Rendering Work State
Application::agentReplyWorkForPost() and the surrounding agentReplyWorkByPostId() configuration gate must follow the generated-response row contract above:
if
DEDALUS_AGENT_REPLIES_ENABLEDis disabled, render no automatic workif the target post is authored by the
reply-agent, render no automatic workno current analysis:
analyzecompleted current analysis and gates pass with no generated-response row:
publishcompleted current analysis and gates pass with a
completegenerated-response row and noagent_post_id:publishcompleted current analysis and gates fail:
noneanalysis exists but is not complete:
noneexisting generated-response row with
agent_post_id:noneexisting generated-response row with
pending,posting, orfailed:none
After the new one-step path succeeds, a reload should find an agent reply row with agent_post_id, so the created post card should not render data-agent-reply-work.
6. Tests
Add or update tests in tests/WriteApiSmokeTest.php.
Required coverage:
POST /api/analyze_postposts an agent reply when analysis passes gates.The response includes the embedded agent reply status and post URL.
The canonical reply file exists.
The reply has
Parent-IDequal to the analyzed post ID.The reply is authored by the
reply-agentidentity.The generated response row uses the analysis provider/model.
The generated response row's decoded
raw_response.sourceisanalysis_suggested_response.Repeating
POST /api/analyze_postfor the same content does not post a duplicate reply.Existing
/api/generate_agent_replybehavior remains idempotent and compatible.Low respondability, high risk, high moderation, private response, disabled config, and agent-loop cases do not post from
/api/analyze_post.For unauthorized viewers, gate-failed
/api/analyze_postresponses use genericagent_reply_reason: "not_recommended".For authorized viewers, gate-failed
/api/analyze_postresponses may include the concrete gate reason./api/analyze_postnever exposes replyresponse_text, reply provider/model,raw_response,failure_message, orretry_afterthrough the compact agent reply fields.Direct
/api/analyze_postwhileDEDALUS_AGENT_REPLIES_ENABLED=falsereturnsagent_reply_generation_allowed: false,agent_reply_generation_status: "not_recommended", andagent_reply_reason: "config_disabled"without posting.Pages rendered while
DEDALUS_AGENT_REPLIES_ENABLED=falsedo not renderdata-agent-reply-work.After a successful one-step
/api/analyze_post, a reload renders nodata-agent-reply-workfor the created post.Existing tests that expected a reload to render
data-agent-reply-work="publish"after analysis are updated to the new posted/no-work expectation.Add a source-level JS assertion in
tests/WriteApiSmokeTest.phpunless a dedicated JS test harness already exists:public/assets/post_analysis.jsmust not callgenerateAgentReply(postId)from thework === "analyze"branch, and must still call it for thework === "publish"branch.
7. Verification
Run:
php tests/run.php
node --check public/assets/post_analysis.js
Manual production-like check:
Create a new post.
Open the redirected thread URL with
created_post_id.Confirm the browser makes one
POST /api/analyze_postcall for the new post.Confirm an agent reply is posted from the suggested response.
Reload the thread.
Confirm the reply is visible and no extra automatic posting occurs.
Expected Outcome
The automatic path becomes one backend step from the browser's perspective. There is still only one model call, and the stored suggested response is posted immediately after the analysis passes gates. The separate /api/generate_agent_reply endpoint remains available for older clients, already-analyzed posts, and manual retry workflows.
Implementation Status
2026-05-12: Step 0 complete on branch
agent-reply-one-step-analyze-publish- added the implementation contract document and this status log. No runtime behavior changed.2026-05-12: Step 1 complete - extracted
Application::agentReplyResultForPost()as the shared array-returning reply posting helper and changedPOST /api/generate_agent_replyto delegate after request validation. Verified withphp -l src/ForumRewrite/Application.php.2026-05-12: Step 2 complete - wired
POST /api/analyze_postto publish through the shared helper when config, completed analysis, and gates allow it; added compact allowlistedagent_reply_*response mapping with local skipped-case summaries. Verified withphp -l src/ForumRewrite/Application.php.2026-05-12: Step 3 complete - updated
public/assets/post_analysis.jssowork === "analyze"calls only/api/analyze_post, adapts embeddedagent_reply_*fields withagentReplyResultFromAnalysis(), and leaveswork === "publish"on/api/generate_agent_reply. Verified withnode --check public/assets/post_analysis.js.2026-05-12: Step 4 complete - tightened
agentReplyWorkForPost()generated-response row handling so only an unpostedcompleterow can renderpublish; posted, pending, posting, failed, and unknown row states render no automatic work. Verified withphp -l src/ForumRewrite/Application.php.2026-05-12: Step 5 complete - updated smoke coverage for one-step
/api/analyze_postposting, embedded compact response fields, canonical reply creation, generated-response metadata, idempotent repeat analysis, disabled config, gate-failure privacy, and JS source branching. Verified withphp tests/run.php.2026-05-12: Step 6 complete - final verification passed with
php tests/run.phpandnode --check public/assets/post_analysis.js.