From e43e88d0c9993cdb7672f97dbc0d257e5b02fcc1 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Mon, 20 Apr 2026 19:32:24 +0300 Subject: [PATCH 01/14] Fixed some bugs --- .../senior-enterprise-java/MEMORY.md | 3 + ...ack_conditional_on_bean_for_agent_guard.md | 11 + .../feedback_migration_location.md | 11 + .../feedback_test_classpath.md | 11 + .../fixture/config/TelegramFixtureConfig.java | 4 +- .../telegram/TelegramBotStartCommandIT.java | 2 +- .../it/telegram/TelegramMockGatewayIT.java | 4 +- .../MessageTelegramCommandHandlerIT.java | 4 +- .../src/main/resources/application.yml | 1 + .../opendaimon/common/agent/README.md | 14 +- .../common/config/FeatureToggle.java | 4 +- .../opendaimon/common/model/User.java | 8 + .../core/V12__Add_agent_mode_to_user.sql | 2 + .../ai/springai/agent/ReActAgentExecutor.java | 12 +- .../agent/SpringAgentLoopActions.java | 6 + .../StrategyDelegatingAgentExecutor.java | 8 +- opendaimon-telegram/TELEGRAM_MODULE.md | 17 ++ .../telegram/command/TelegramCommand.java | 1 + .../impl/ModeTelegramCommandHandler.java | 174 +++++++++++ .../fsm/TelegramMessageHandlerActions.java | 31 +- .../config/TelegramCommandHandlerConfig.java | 19 +- .../config/TelegramServiceConfig.java | 7 +- .../telegram/service/TelegramUserService.java | 20 ++ .../resources/messages/telegram_en.properties | 8 + .../resources/messages/telegram_ru.properties | 8 + .../MessageTelegramCommandHandlerTest.java | 2 +- .../impl/ModeTelegramCommandHandlerTest.java | 279 ++++++++++++++++++ ...elegramMessageHandlerActionsAgentTest.java | 38 ++- ...ramMessageHandlerActionsStreamingTest.java | 2 +- ...essageHandlerActionsTentativeEditTest.java | 2 +- .../service/TelegramUserServiceTest.java | 75 ++++- 31 files changed, 761 insertions(+), 27 deletions(-) create mode 100644 .claude/agent-memory/senior-enterprise-java/feedback_conditional_on_bean_for_agent_guard.md create mode 100644 .claude/agent-memory/senior-enterprise-java/feedback_migration_location.md create mode 100644 .claude/agent-memory/senior-enterprise-java/feedback_test_classpath.md create mode 100644 opendaimon-common/src/main/resources/db/migration/core/V12__Add_agent_mode_to_user.sql create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandlerTest.java diff --git a/.claude/agent-memory/senior-enterprise-java/MEMORY.md b/.claude/agent-memory/senior-enterprise-java/MEMORY.md index e80f4958..6ee40e2a 100644 --- a/.claude/agent-memory/senior-enterprise-java/MEMORY.md +++ b/.claude/agent-memory/senior-enterprise-java/MEMORY.md @@ -1,2 +1,5 @@ - [User Role and Expertise](user_role.md) — senior Java developer on open-daimon, expects clean architecture and proper module boundaries - [RAG storage refactor](project_rag_storage_refactor.md) — RAG documentIds moved from thread.memoryBullets to message.metadata; handler update still pending +- [Migration files live in opendaimon-common](feedback_migration_location.md) — core migrations in opendaimon-common, not opendaimon-app; next free version from glob there +- [Install updated modules before testing dependents](feedback_test_classpath.md) — mvnw install -pl -DskipTests needed when shared module changed +- [Agent guard: use @ConditionalOnBean(AgentExecutor.class)](feedback_conditional_on_bean_for_agent_guard.md) — cleaner than stacking @ConditionalOnProperty for agent.enabled in handler beans diff --git a/.claude/agent-memory/senior-enterprise-java/feedback_conditional_on_bean_for_agent_guard.md b/.claude/agent-memory/senior-enterprise-java/feedback_conditional_on_bean_for_agent_guard.md new file mode 100644 index 00000000..d6184942 --- /dev/null +++ b/.claude/agent-memory/senior-enterprise-java/feedback_conditional_on_bean_for_agent_guard.md @@ -0,0 +1,11 @@ +--- +name: Use @ConditionalOnBean(AgentExecutor.class) as agent-enabled guard in handlers +description: For command handlers that require agent module, use @ConditionalOnBean(AgentExecutor.class) instead of @ConditionalOnProperty for agent.enabled +type: feedback +--- + +`AgentExecutor` bean is only created when `open-daimon.agent.enabled=true`. To guard a handler bean on agent being enabled, use `@ConditionalOnBean(AgentExecutor.class)` — cleaner and semantically correct compared to a second `@ConditionalOnProperty` which can have stacking/ordering issues. + +**Why:** Spring Boot `@ConditionalOnProperty` is repeatable but when stacking two on the same method for unrelated prefixes, the behavior can be surprising. `@ConditionalOnBean` expresses the real dependency and is unambiguous. + +**How to apply:** Command handlers only valid when agent module is active should declare `@ConditionalOnBean(AgentExecutor.class)` alongside their `@ConditionalOnProperty` for the command toggle. diff --git a/.claude/agent-memory/senior-enterprise-java/feedback_migration_location.md b/.claude/agent-memory/senior-enterprise-java/feedback_migration_location.md new file mode 100644 index 00000000..036537e0 --- /dev/null +++ b/.claude/agent-memory/senior-enterprise-java/feedback_migration_location.md @@ -0,0 +1,11 @@ +--- +name: Migration files live in opendaimon-common, not opendaimon-app +description: DB migrations for the core "user" table are in opendaimon-common/src/main/resources/db/migration/core/, not opendaimon-app +type: feedback +--- + +Core DB migrations (user table, agent tables, etc.) live in `opendaimon-common/src/main/resources/db/migration/core/`, not in `opendaimon-app`. The plan said `opendaimon-app` but inspection confirmed the correct location. + +**Why:** Flyway is configured per module; common migrations travel with `opendaimon-common`. + +**How to apply:** When adding a migration for a base entity, always glob `opendaimon-common/**/migration/core/V*.sql` to find the next free version number. diff --git a/.claude/agent-memory/senior-enterprise-java/feedback_test_classpath.md b/.claude/agent-memory/senior-enterprise-java/feedback_test_classpath.md new file mode 100644 index 00000000..14d693cf --- /dev/null +++ b/.claude/agent-memory/senior-enterprise-java/feedback_test_classpath.md @@ -0,0 +1,11 @@ +--- +name: Run mvnw install on updated modules before testing dependent modules +description: When a module dependency is modified, install it first; otherwise test compilation of dependent modules will use the stale JAR from the local Maven repo +type: feedback +--- + +`./mvnw test -pl opendaimon-telegram` uses the installed JAR of `opendaimon-common` from `~/.m2`. If `opendaimon-common` was just modified, run `./mvnw install -pl opendaimon-common -DskipTests` first, otherwise test compilation in `opendaimon-telegram` will see stale symbols. + +**Why:** Maven test classpath resolution for single-module builds uses installed artifacts, not reactor targets. + +**How to apply:** After editing any shared module (`opendaimon-common`, `opendaimon-bulkhead`, etc.), always `mvnw install -pl -DskipTests` before running tests in a dependent module without `-am`. diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java index 0ccb8aca..cadad27e 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java @@ -223,7 +223,7 @@ public TelegramUserService telegramUserService( TelegramUserRepository telegramUserRepository, TelegramUserSessionService telegramUserSessionService, AssistantRoleService assistantRoleService) { - return new TelegramUserService(telegramUserRepository, telegramUserSessionService, assistantRoleService); + return new TelegramUserService(telegramUserRepository, telegramUserSessionService, assistantRoleService, false); } @Bean @@ -326,7 +326,7 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, userModelPreferenceService, persistentKeyboardService, replyImageAttachmentService, - messageSender, null, null, 10); + messageSender, null, null, 10, false); ExDomainFsm handlerFsm = MessageHandlerFsmFactory.create(actions); return new MessageTelegramCommandHandler( diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramBotStartCommandIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramBotStartCommandIT.java index a92890af..20957a6f 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramBotStartCommandIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramBotStartCommandIT.java @@ -162,7 +162,7 @@ public TelegramUserService telegramUserService( TelegramUserRepository telegramUserRepository, TelegramUserSessionService telegramUserSessionService, AssistantRoleService assistantRoleService) { - return new TelegramUserService(telegramUserRepository, telegramUserSessionService, assistantRoleService); + return new TelegramUserService(telegramUserRepository, telegramUserSessionService, assistantRoleService, false); } @Bean diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java index fe698bab..da88e414 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java @@ -258,7 +258,7 @@ public TelegramUserService telegramUserService( TelegramUserSessionService telegramUserSessionService, AssistantRoleService assistantRoleService ) { - return new TelegramUserService(telegramUserRepository, telegramUserSessionService, assistantRoleService); + return new TelegramUserService(telegramUserRepository, telegramUserSessionService, assistantRoleService, false); } @Bean @@ -370,7 +370,7 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, userModelPreferenceService, persistentKeyboardService, replyImageAttachmentService, - messageSender, null, null, 10); + messageSender, null, null, 10, false); ExDomainFsm handlerFsm = MessageHandlerFsmFactory.create(actions); return new MessageTelegramCommandHandler( diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java index 33be3393..d3568122 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java @@ -229,7 +229,7 @@ public TelegramUserService telegramUserService( TelegramUserRepository telegramUserRepository, TelegramUserSessionService telegramUserSessionService, AssistantRoleService assistantRoleService) { - return new TelegramUserService(telegramUserRepository, telegramUserSessionService, assistantRoleService); + return new TelegramUserService(telegramUserRepository, telegramUserSessionService, assistantRoleService, false); } @Bean @@ -325,7 +325,7 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, userModelPreferenceService, persistentKeyboardService, replyImageAttachmentService, - messageSender, null, null, 10); + messageSender, null, null, 10, false); ExDomainFsm handlerFsm = MessageHandlerFsmFactory.create(actions); return new MessageTelegramCommandHandler( diff --git a/opendaimon-app/src/main/resources/application.yml b/opendaimon-app/src/main/resources/application.yml index f1a97499..f0d64304 100644 --- a/opendaimon-app/src/main/resources/application.yml +++ b/opendaimon-app/src/main/resources/application.yml @@ -122,6 +122,7 @@ open-daimon: threads-enabled: true language-enabled: true model-enabled: true + mode-enabled: true cache: redis-enabled: false # FEATURE FLAG - enable distributed Redis cache for session data message-coalescing: diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/README.md b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/README.md index 6fa5f052..48e6183e 100644 --- a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/README.md +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/README.md @@ -46,9 +46,17 @@ ANSWERING ──[auto]──> COMPLETED (terminal) ## Sequence: Telegram Message → Agent Execution -Agent mode is activated at the application level via `open-daimon.agent.enabled=true`. -When enabled, `TelegramMessageHandlerActions.generateResponse()` delegates to `AgentExecutor` -directly (no separate command/handler layer). +Agent mode has dual semantics controlled by `open-daimon.agent.enabled`: + +1. **Module gate** — when `false`, no `AgentExecutor` bean is created and the entire agent module + is inactive. All requests go through `AIGateway`. The `/mode` Telegram command is not registered. +2. **Default for new users** — when `true`, new `TelegramUser` records are created with + `agentModeEnabled=true`. Existing users with `agentModeEnabled=null` also resolve to `true`. + Individual users can override this default via the `/mode` Telegram command. + +When `open-daimon.agent.enabled=true`, `TelegramMessageHandlerActions.generateResponse()` delegates +to `AgentExecutor` only when the per-user flag resolves to `true` +(`user.agentModeEnabled != null ? user.agentModeEnabled : defaultAgentModeEnabled`). ``` User TelegramBot MessageHandler(FSM) TelegramMessageHandlerActions StrategyDelegating ReActExecutor FSM SpringAgentLoopActions LLM ToolCallingManager diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java index 3590cd77..f3f703cb 100644 --- a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java @@ -75,6 +75,7 @@ private TelegramCommand() { public static final String THREADS = "threads-enabled"; public static final String MESSAGE = "message-enabled"; public static final String MODEL = "model-enabled"; + public static final String MODE = "mode-enabled"; } // ── OpenRouter model rotation toggles (prefix-based) ──────── @@ -123,7 +124,8 @@ public enum Toggle { CMD_HISTORY(TelegramCommand.PREFIX + "." + TelegramCommand.HISTORY), CMD_THREADS(TelegramCommand.PREFIX + "." + TelegramCommand.THREADS), CMD_MESSAGE(TelegramCommand.PREFIX + "." + TelegramCommand.MESSAGE), - CMD_MODEL(TelegramCommand.PREFIX + "." + TelegramCommand.MODEL); + CMD_MODEL(TelegramCommand.PREFIX + "." + TelegramCommand.MODEL), + CMD_MODE(TelegramCommand.PREFIX + "." + TelegramCommand.MODE); private final String propertyKey; diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/User.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/User.java index a118e5c3..c9e3f012 100644 --- a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/User.java +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/User.java @@ -59,6 +59,14 @@ public class User extends AbstractEntity implements IUserObject { @Column(name = "preferred_model_id") private String preferredModelId; + /** + * Per-user agent mode flag. {@code null} means "use application default" + * ({@code open-daimon.agent.enabled}). Set to {@code true}/{@code false} + * explicitly via the {@code /mode} Telegram command. + */ + @Column(name = "agent_mode_enabled") + private Boolean agentModeEnabled; + /** * Current active assistant role */ diff --git a/opendaimon-common/src/main/resources/db/migration/core/V12__Add_agent_mode_to_user.sql b/opendaimon-common/src/main/resources/db/migration/core/V12__Add_agent_mode_to_user.sql new file mode 100644 index 00000000..73194842 --- /dev/null +++ b/opendaimon-common/src/main/resources/db/migration/core/V12__Add_agent_mode_to_user.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS agent_mode_enabled BOOLEAN; diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java index e09e2d26..d1c5befe 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java @@ -60,11 +60,16 @@ public AgentResult execute(AgentRequest request) { @Override public Flux executeStream(AgentRequest request) { Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); + log.info("AGENT_TRACE: sink created"); - Flux eventFlux = sink.asFlux(); + Flux eventFlux = sink.asFlux() + .doOnSubscribe(s -> log.info("AGENT_TRACE: eventFlux subscribed")) + .doOnCancel(() -> log.info("AGENT_TRACE: eventFlux cancelled")) + .doOnError(e -> log.info("AGENT_TRACE: eventFlux errored: {}", e.toString())); // Run FSM in a bounded elastic thread to avoid blocking the caller Flux.defer(() -> { + log.info("AGENT_TRACE: defer body started on thread={}", Thread.currentThread().getName()); try { AgentContext ctx = new AgentContext( request.task(), @@ -73,14 +78,17 @@ public Flux executeStream(AgentRequest request) { request.maxIterations(), request.enabledTools() ); + log.info("AGENT_TRACE: AgentContext constructed, conversationId={}", request.conversationId()); // Install an event listener on the context ctx.setStreamSink(sink::tryEmitNext); + log.info("AGENT_TRACE: agentFsm.handle START called"); agentFsm.handle(ctx, AgentEvent.START); // Emit metadata (model name) before terminal event AgentResult result = ctx.toResult(); + log.info("AGENT_TRACE: agentFsm.handle START returned, result.terminalState={}", result.terminalState()); if (result.modelName() != null) { sink.tryEmitNext(AgentStreamEvent.metadata( result.modelName(), result.iterationsUsed())); @@ -118,7 +126,9 @@ public Flux executeStream(AgentRequest request) { } sink.tryEmitComplete(); + log.info("AGENT_TRACE: sink tryEmitComplete called"); } catch (Exception e) { + log.info("AGENT_TRACE: defer body threw {}: {}", e.getClass().getName(), e.getMessage()); log.error("Agent stream execution failed: {}", e.getMessage(), e); sink.tryEmitNext(AgentStreamEvent.error(e.getMessage(), 0)); sink.tryEmitError(e); diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java index 36e6ca53..80e14ae7 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java @@ -138,6 +138,8 @@ public SpringAgentLoopActions(ChatModel chatModel, @Override public void think(AgentContext ctx) { + log.info("AGENT_TRACE: SpringAgentLoopActions.think entered, iteration={}, thread={}", + ctx.getCurrentIteration(), Thread.currentThread().getName()); if (ctx.isCancelled()) { ctx.setErrorMessage("Agent run cancelled by user before think()"); return; @@ -175,6 +177,8 @@ public void think(AgentContext ctx) { .collect(Collectors.joining("\n---\n"))); } + log.info("AGENT_TRACE: calling LLM model={}, iteration={}", + preferredModelId != null ? preferredModelId : "default", ctx.getCurrentIteration()); ChatResponse response = streamAndAggregate(ctx, prompt); if (response == null) { ctx.setErrorMessage("LLM returned an empty stream"); @@ -414,6 +418,7 @@ public void executeTool(AgentContext ctx) { } log.info("Agent executeTool: tool={}", ctx.getCurrentToolName()); + log.info("AGENT_TRACE: invoking tool {}", ctx.getCurrentToolName()); ToolExecutionResult toolResult = toolCallingManager.executeToolCalls(prompt, response); @@ -760,6 +765,7 @@ private void executeFallbackToolCall(AgentContext ctx) { } log.info("Agent executeTool (fallback): tool={}, args={}", toolName, toolArgs); + log.info("AGENT_TRACE: invoking tool {} (fallback path)", toolName); String result = guardFetchUrlCallback(ctx, callback).call(toolArgs); ctx.setToolResult(AgentToolResult.success(toolName, result)); diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/StrategyDelegatingAgentExecutor.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/StrategyDelegatingAgentExecutor.java index 337929eb..1668de39 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/StrategyDelegatingAgentExecutor.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/StrategyDelegatingAgentExecutor.java @@ -55,11 +55,17 @@ public AgentResult execute(AgentRequest request) { public Flux executeStream(AgentRequest request) { AgentStrategy strategy = resolveStrategy(request); log.info("Agent stream strategy resolved: requested={}, resolved={}", request.strategy(), strategy); + log.info("AGENT_TRACE: StrategyDelegatingAgentExecutor.executeStream entered, strategy={}", strategy); return switch (strategy) { case SIMPLE -> simpleExecutor.executeStream(request); case PLAN_AND_EXECUTE -> planAndExecuteExecutor.executeStream(request); - case REACT, AUTO -> reactExecutor.executeStream(request); + case REACT, AUTO -> { + log.info("AGENT_TRACE: delegating to reactExecutor"); + Flux flux = reactExecutor.executeStream(request); + log.info("AGENT_TRACE: reactExecutor returned Flux"); + yield flux; + } }; } diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index 16a7da33..e6b228c7 100644 --- a/opendaimon-telegram/TELEGRAM_MODULE.md +++ b/opendaimon-telegram/TELEGRAM_MODULE.md @@ -450,6 +450,23 @@ It replaces the paragraph-streaming step of UC-1 (and related text-message UCs) - Resolved `AgentStrategy = REACT` — see `StrategyDelegatingAgentExecutor#resolveStrategy` (triggered when the selected model has capability `WEB` or `AUTO` and at least one tool is registered) +### Per-user override + +Each user has a `agentModeEnabled` flag on the `User` entity (nullable `Boolean`): +- `null` — falls back to the application default (`open-daimon.agent.enabled`). +- `true` / `false` — overrides the default for that user regardless of the global setting. + +**Default for new users:** set to the value of `open-daimon.agent.enabled` at user creation time. + +**Switching:** users can toggle their mode via the `/mode` Telegram command (inline keyboard: AGENT / REGULAR / Close). +The `/mode` command bean is only registered when `open-daimon.agent.enabled=true` AND +`open-daimon.telegram.commands.mode-enabled=true` (default: `true`). + +**When `agent.enabled=false`:** `AgentExecutor` bean is absent, `/mode` is not registered, and all users go through +the AI gateway regardless of their stored preference. + +**Routing rule:** The gateway path is taken when `AgentExecutor` bean is absent **or** the user has disabled agent mode via `/mode`; the agent path requires both the bean and the per-user flag to be enabled. This predicate is enforced consistently in both `createCommand` (gateway lookup) and `generateResponse` (branch selection). + The loop is driven by our own FSM (`SpringAgentLoopActions`). Spring AI's built-in tool-execution loop is explicitly disabled via `ToolCallingChatOptions.internalToolExecutionEnabled=false` — we pass tools to Spring AI but keep iteration control on our side. `SimpleChainExecutor` does not diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java index 5b11ca28..a528fdf8 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java @@ -25,6 +25,7 @@ public class TelegramCommand implements IChatCommand { public static final String THREADS = "/threads"; public static final String LANGUAGE = "/language"; public static final String MODEL = "/model"; + public static final String MODE = "/mode"; public static final String MODEL_KEYBOARD_PREFIX = "🤖"; public static final String CONTEXT_KEYBOARD_PREFIX = "💬"; diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java new file mode 100644 index 00000000..2a2ebf52 --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java @@ -0,0 +1,174 @@ +package io.github.ngirchev.opendaimon.telegram.command.handler.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage; +import org.telegram.telegrambots.meta.api.objects.CallbackQuery; +import org.telegram.telegrambots.meta.api.objects.Message; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; +import io.github.ngirchev.opendaimon.common.command.ICommand; +import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; +import io.github.ngirchev.opendaimon.telegram.TelegramBot; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType; +import io.github.ngirchev.opendaimon.telegram.command.handler.AbstractTelegramCommandHandlerWithResponseSend; +import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramCommandHandlerException; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; +import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; + +import java.util.List; + +@Slf4j +public class ModeTelegramCommandHandler extends AbstractTelegramCommandHandlerWithResponseSend { + + private static final String CALLBACK_PREFIX = "MODE_"; + private static final String CALLBACK_CANCEL = CALLBACK_PREFIX + "CANCEL"; + private static final String CALLBACK_AGENT = CALLBACK_PREFIX + "AGENT"; + private static final String CALLBACK_REGULAR = CALLBACK_PREFIX + "REGULAR"; + + private final TelegramUserService telegramUserService; + + public ModeTelegramCommandHandler(ObjectProvider telegramBotProvider, + TypingIndicatorService typingIndicatorService, + MessageLocalizationService messageLocalizationService, + TelegramUserService telegramUserService) { + super(telegramBotProvider, typingIndicatorService, messageLocalizationService); + this.telegramUserService = telegramUserService; + } + + @Override + public String getSupportedCommandText(String languageCode) { + return messageLocalizationService.getMessage("telegram.command.mode.desc", languageCode); + } + + @Override + protected boolean shouldShowTypingIndicator(TelegramCommand command) { + return false; + } + + @Override + public boolean canHandle(ICommand command) { + if (!(command instanceof TelegramCommand telegramCommand)) { + return false; + } + if (telegramCommand.update().hasCallbackQuery()) { + CallbackQuery cq = telegramCommand.update().getCallbackQuery(); + return cq.getData() != null && cq.getData().startsWith(CALLBACK_PREFIX); + } + var commandType = command.commandType(); + return commandType != null + && commandType.command() != null + && commandType.command().equals(TelegramCommand.MODE); + } + + @Override + public String handleInner(TelegramCommand command) { + if (command.update().hasCallbackQuery()) { + handleCallbackQuery(command); + return null; + } + Message message = command.update().getMessage(); + if (message == null) { + throw new TelegramCommandHandlerException(command.telegramId(), "Message is required for mode command"); + } + TelegramUser user = telegramUserService.getOrCreateUser(message.getFrom()); + Boolean currentMode = user.getAgentModeEnabled(); + String currentLabel = modeLabel(currentMode, command.languageCode()); + String currentMsg = messageLocalizationService.getMessage("telegram.mode.current", command.languageCode(), currentLabel); + sendModeMenu(command.telegramId(), command.languageCode(), currentMsg); + return null; + } + + private void handleCallbackQuery(TelegramCommand command) { + CallbackQuery cq = command.update().getCallbackQuery(); + String callbackData = cq.getData(); + if (callbackData == null || !callbackData.startsWith(CALLBACK_PREFIX)) { + throw new TelegramCommandHandlerException(command.telegramId(), "Invalid callback data"); + } + if (CALLBACK_CANCEL.equals(callbackData)) { + ackCallback(cq.getId(), ""); + deleteMenuMessage(command.telegramId(), cq); + return; + } + if (CALLBACK_AGENT.equals(callbackData)) { + telegramUserService.updateAgentMode(cq.getFrom().getId(), true); + String label = messageLocalizationService.getMessage("telegram.mode.label.agent", command.languageCode()); + String updatedMsg = messageLocalizationService.getMessage("telegram.mode.updated", command.languageCode(), label); + ackCallback(cq.getId(), updatedMsg); + deleteMenuMessage(command.telegramId(), cq); + return; + } + if (CALLBACK_REGULAR.equals(callbackData)) { + telegramUserService.updateAgentMode(cq.getFrom().getId(), false); + String label = messageLocalizationService.getMessage("telegram.mode.label.regular", command.languageCode()); + String updatedMsg = messageLocalizationService.getMessage("telegram.mode.updated", command.languageCode(), label); + ackCallback(cq.getId(), updatedMsg); + deleteMenuMessage(command.telegramId(), cq); + return; + } + ackCallback(cq.getId(), "❌"); + sendErrorMessage(command.telegramId(), messageLocalizationService.getMessage("telegram.mode.unknown", command.languageCode())); + } + + private void sendModeMenu(Long chatId, String languageCode, String currentMsg) { + try { + String labelAgent = messageLocalizationService.getMessage("telegram.mode.label.agent", languageCode); + String labelRegular = messageLocalizationService.getMessage("telegram.mode.label.regular", languageCode); + List modeRow = List.of( + button(labelAgent, CALLBACK_AGENT), + button(labelRegular, CALLBACK_REGULAR) + ); + String closeLabel = messageLocalizationService.getMessage("telegram.mode.close", languageCode); + InlineKeyboardMarkup markup = new InlineKeyboardMarkup(List.of( + modeRow, + List.of(button(closeLabel, CALLBACK_CANCEL)) + )); + String selectText = messageLocalizationService.getMessage("telegram.mode.select", languageCode); + SendMessage msg = new SendMessage(chatId.toString(), currentMsg + "\n\n" + selectText); + msg.setReplyMarkup(markup); + telegramBotProvider.getObject().execute(msg); + } catch (Exception e) { + throw new TelegramCommandHandlerException("Failed to send mode menu", e); + } + } + + private InlineKeyboardButton button(String label, String callbackData) { + InlineKeyboardButton button = new InlineKeyboardButton(label); + button.setCallbackData(callbackData); + return button; + } + + private String modeLabel(Boolean agentModeEnabled, String languageCode) { + if (Boolean.TRUE.equals(agentModeEnabled)) { + return messageLocalizationService.getMessage("telegram.mode.label.agent", languageCode); + } + return messageLocalizationService.getMessage("telegram.mode.label.regular", languageCode); + } + + private void ackCallback(String callbackQueryId, String text) { + try { + AnswerCallbackQuery ack = new AnswerCallbackQuery(); + ack.setCallbackQueryId(callbackQueryId); + ack.setText(text); + ack.setShowAlert(false); + telegramBotProvider.getObject().execute(ack); + } catch (Exception e) { + throw new TelegramCommandHandlerException("Failed to ack callback", e); + } + } + + private void deleteMenuMessage(Long chatId, CallbackQuery callbackQuery) { + if (callbackQuery.getMessage() instanceof Message menuMessage) { + try { + telegramBotProvider.getObject().execute( + new DeleteMessage(chatId.toString(), menuMessage.getMessageId())); + } catch (Exception e) { + log.warn("Failed to delete mode menu message: {}", e.getMessage()); + } + } + } +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java index ff445d0b..e0854da1 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java @@ -145,6 +145,11 @@ private static int maxLength(String[] arr) { private final TelegramAgentStreamRenderer agentStreamRenderer; /** Agent max iterations — only used when {@code agentExecutor} is non-null. */ private final int agentMaxIterations; + /** + * Application-level default for agent mode. Mirrors {@code open-daimon.agent.enabled}. + * Used as fallback when {@code TelegramUser.agentModeEnabled} is {@code null}. + */ + private final boolean defaultAgentModeEnabled; @Override public void resolveUser(MessageHandlerContext ctx) { @@ -259,8 +264,9 @@ public void createCommand(MessageHandlerContext ctx) { ctx.setAiCommand(aiCommand); ctx.setModelCapabilities(aiCommand.modelCapabilities()); - // Agent mode uses AgentExecutor, not AIGateway — skip gateway lookup - if (agentExecutor == null) { + // Gateway path is taken when agent bean is absent OR user disabled agent mode — + // mirror the predicate used in generateResponse to keep FSM state consistent. + if (agentExecutor == null || !isAgentModeEnabledForUser(ctx)) { AIGateway aiGateway = aiGatewayRegistry.getSupportedAiGateways(aiCommand) .stream() .findFirst() @@ -268,7 +274,9 @@ public void createCommand(MessageHandlerContext ctx) { ctx.setAiGateway(aiGateway); } - log.debug("FSM createCommand: capabilities={}, agentMode={}", aiCommand.modelCapabilities(), agentExecutor != null); + log.debug("FSM createCommand: capabilities={}, agentPath={}", + aiCommand.modelCapabilities(), + agentExecutor != null && isAgentModeEnabledForUser(ctx)); } catch (UserMessageTooLongException e) { ctx.setErrorType(MessageHandlerErrorType.MESSAGE_TOO_LONG); ctx.setException(e); @@ -285,13 +293,26 @@ public void createCommand(MessageHandlerContext ctx) { @Override public void generateResponse(MessageHandlerContext ctx) { - if (agentExecutor != null) { + if (agentExecutor != null && isAgentModeEnabledForUser(ctx)) { generateAgentResponse(ctx); } else { generateGatewayResponse(ctx); } } + /** + * Returns {@code true} when the user has agent mode enabled. + * Uses the per-user flag if set; falls back to {@code defaultAgentModeEnabled}. + */ + private boolean isAgentModeEnabledForUser(MessageHandlerContext ctx) { + TelegramUser user = ctx.getTelegramUser(); + if (user == null) { + return defaultAgentModeEnabled; + } + Boolean flag = user.getAgentModeEnabled(); + return flag != null ? flag : defaultAgentModeEnabled; + } + private void generateAgentResponse(MessageHandlerContext ctx) { TelegramCommand command = ctx.getCommand(); Map metadata = ctx.getMetadata(); @@ -339,7 +360,7 @@ private void generateAgentResponse(MessageHandlerContext ctx) { handleStreamError(ctx, err); return reactor.core.publisher.Flux.empty(); }) - .blockLast(); + .blockLast(java.time.Duration.ofSeconds(90)); finalizeAfterStream(ctx, lastEvent); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java index 23beeb43..0496dea4 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java @@ -105,6 +105,19 @@ public LanguageTelegramCommandHandler languageTelegramCommandHandler( typingIndicatorService, messageLocalizationService, telegramUserService, telegramBotMenuService); } + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(AgentExecutor.class) + @ConditionalOnProperty(prefix = FeatureToggle.TelegramCommand.PREFIX, name = FeatureToggle.TelegramCommand.MODE, havingValue = "true", matchIfMissing = true) + public ModeTelegramCommandHandler modeTelegramCommandHandler( + ObjectProvider telegramBotProvider, + TypingIndicatorService typingIndicatorService, + MessageLocalizationService messageLocalizationService, + TelegramUserService telegramUserService) { + return new ModeTelegramCommandHandler(telegramBotProvider, + typingIndicatorService, messageLocalizationService, telegramUserService); + } + @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = FeatureToggle.TelegramCommand.PREFIX, name = FeatureToggle.TelegramCommand.NEW_THREAD, havingValue = "true", matchIfMissing = true) @@ -208,13 +221,15 @@ public TelegramMessageHandlerActions messageHandlerActions( ObjectProvider agentExecutorProvider, TelegramAgentStreamRenderer agentStreamRenderer, // No default here — all defaults live in application.yml only (see coding-style.md) - @Value("${open-daimon.agent.max-iterations}") int agentMaxIterations) { + @Value("${open-daimon.agent.max-iterations}") int agentMaxIterations, + @Value("${open-daimon.agent.enabled:false}") boolean defaultAgentModeEnabled) { return new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, userModelPreferenceService, persistentKeyboardService, replyImageAttachmentService, telegramMessageSender, - agentExecutorProvider.getIfAvailable(), agentStreamRenderer, agentMaxIterations); + agentExecutorProvider.getIfAvailable(), agentStreamRenderer, agentMaxIterations, + defaultAgentModeEnabled); } @Bean diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java index bf501ee7..120550e6 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java @@ -1,6 +1,7 @@ package io.github.ngirchev.opendaimon.telegram.config; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.beans.factory.annotation.Qualifier; @@ -39,8 +40,10 @@ public class TelegramServiceConfig { public TelegramUserService telegramUserService( TelegramUserRepository telegramUserRepository, TelegramUserSessionService telegramUserSessionService, - AssistantRoleService assistantRoleService) { - return new TelegramUserService(telegramUserRepository, telegramUserSessionService, assistantRoleService); + AssistantRoleService assistantRoleService, + @Value("${open-daimon.agent.enabled:false}") boolean defaultAgentModeEnabled) { + return new TelegramUserService(telegramUserRepository, telegramUserSessionService, assistantRoleService, + defaultAgentModeEnabled); } @Bean diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java index 2ee18ed0..57e0265d 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java @@ -28,6 +28,8 @@ public class TelegramUserService implements IUserService { private final TelegramUserRepository telegramUserRepository; private final TelegramUserSessionService telegramUserSessionService; private final AssistantRoleService assistantRoleService; + /** Default value for {@code agentModeEnabled} on new users. Sourced from {@code open-daimon.agent.enabled}. */ + private final boolean defaultAgentModeEnabled; @Override public Optional findById(Long id) { @@ -135,6 +137,23 @@ public TelegramUser updateLanguageCode(Long telegramId, String languageCode) { return telegramUserRepository.save(user); } + /** + * Updates the per-user agent mode flag. + * + * @param telegramId Telegram user id + * @param enabled {@code true} to enable agent mode, {@code false} for regular (gateway) mode + */ + @Transactional + public void updateAgentMode(Long telegramId, boolean enabled) { + TelegramUser user = telegramUserRepository.findByTelegramId(telegramId) + .orElseThrow(() -> new RuntimeException(USER_NOT_FOUND)); + user.setAgentModeEnabled(enabled); + OffsetDateTime now = OffsetDateTime.now(); + user.setUpdatedAt(now); + user.setLastActivityAt(now); + telegramUserRepository.save(user); + } + /** * Updates the bot status in the user's current session. * @@ -186,6 +205,7 @@ private TelegramUser createUserInner(User telegramUser) { user.setLastActivityAt(now); user.setIsBlocked(false); user.setIsAdmin(false); + user.setAgentModeEnabled(defaultAgentModeEnabled); return telegramUserRepository.save(user); } diff --git a/opendaimon-telegram/src/main/resources/messages/telegram_en.properties b/opendaimon-telegram/src/main/resources/messages/telegram_en.properties index 4fa7fb65..4b439277 100644 --- a/opendaimon-telegram/src/main/resources/messages/telegram_en.properties +++ b/opendaimon-telegram/src/main/resources/messages/telegram_en.properties @@ -79,3 +79,11 @@ telegram.threads.conversation.prefix=Conversation\u0020 telegram.threads.more=\n... and {0} more. telegram.threads.close=\u274C Cancel / Close telegram.threads.ack.activated=\u2705 Active: {0} +telegram.command.mode.desc=/mode - switch agent mode +telegram.mode.current=Current mode: {0} +telegram.mode.select=Choose mode: +telegram.mode.label.agent=Agent mode +telegram.mode.label.regular=Regular mode +telegram.mode.updated=Mode switched: {0} +telegram.mode.close=\u274C Cancel / Close +telegram.mode.unknown=Unknown mode diff --git a/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties b/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties index ba40e0b5..3fa3e32f 100644 --- a/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties +++ b/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties @@ -79,3 +79,11 @@ telegram.threads.conversation.prefix=Беседа\u0020 telegram.threads.more=\n... и ещё {0}. telegram.threads.close=\u274C Отмена / закрыть telegram.threads.ack.activated=\u2705 Активна: {0} +telegram.command.mode.desc=/mode - переключить режим агента +telegram.mode.current=Текущий режим: {0} +telegram.mode.select=Выберите режим: +telegram.mode.label.agent=Агентский режим +telegram.mode.label.regular=Обычный режим +telegram.mode.updated=Режим изменён: {0} +telegram.mode.close=\u274C Отмена / закрыть +telegram.mode.unknown=Неизвестный режим diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java index 907835ce..d7633085 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java @@ -126,7 +126,7 @@ void setUp() { telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, userModelPreferenceService, persistentKeyboardService, replyImageAttachmentService, messageSender, - null, null, 10); + null, null, 10, false); ExDomainFsm handlerFsm = MessageHandlerFsmFactory.create(actions); diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandlerTest.java new file mode 100644 index 00000000..dc15d3e0 --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandlerTest.java @@ -0,0 +1,279 @@ +package io.github.ngirchev.opendaimon.telegram.command.handler.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage; +import org.telegram.telegrambots.meta.api.objects.CallbackQuery; +import org.telegram.telegrambots.meta.api.objects.Message; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; +import io.github.ngirchev.opendaimon.common.command.ICommand; +import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; +import io.github.ngirchev.opendaimon.telegram.TelegramBot; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType; +import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramCommandHandlerException; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; +import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; +import org.springframework.beans.factory.ObjectProvider; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ModeTelegramCommandHandlerTest { + + private static final Long CHAT_ID = 100500L; + private static final Long USER_ID = 123L; + + @Mock + private ObjectProvider telegramBotProvider; + @Mock + private TelegramBot telegramBot; + @Mock + private TypingIndicatorService typingIndicatorService; + @Mock + private MessageLocalizationService messageLocalizationService; + @Mock + private TelegramUserService telegramUserService; + + private ModeTelegramCommandHandler handler; + + @BeforeEach + void setUp() { + when(telegramBotProvider.getObject()).thenReturn(telegramBot); + when(messageLocalizationService.getMessage(eq("telegram.command.mode.desc"), anyString())) + .thenReturn("/mode - switch agent mode"); + when(messageLocalizationService.getMessage(eq("telegram.mode.current"), anyString(), anyString())) + .thenReturn("Current mode: {0}"); + when(messageLocalizationService.getMessage(eq("telegram.mode.select"), anyString())) + .thenReturn("Choose mode:"); + when(messageLocalizationService.getMessage(eq("telegram.mode.label.agent"), anyString())) + .thenReturn("Agent mode"); + when(messageLocalizationService.getMessage(eq("telegram.mode.label.regular"), anyString())) + .thenReturn("Regular mode"); + when(messageLocalizationService.getMessage(eq("telegram.mode.updated"), anyString(), anyString())) + .thenReturn("Mode switched: {0}"); + when(messageLocalizationService.getMessage(eq("telegram.mode.close"), anyString())) + .thenReturn("Cancel / Close"); + when(messageLocalizationService.getMessage(eq("telegram.mode.unknown"), anyString())) + .thenReturn("Unknown mode"); + handler = new ModeTelegramCommandHandler( + telegramBotProvider, typingIndicatorService, messageLocalizationService, telegramUserService); + } + + @Test + void canHandle_whenTelegramCommandWithModeCommand_thenTrue() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.MODE), update); + assertTrue(handler.canHandle(command)); + } + + @Test + void canHandle_whenNotTelegramCommand_thenFalse() { + assertFalse(handler.canHandle(mock(ICommand.class))); + } + + @Test + void canHandle_whenCallbackQueryWithModePrefix_thenTrue() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("MODE_AGENT"); + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.MODE), update); + assertTrue(handler.canHandle(command)); + } + + @Test + void canHandle_whenCallbackQueryWithCancel_thenTrue() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("MODE_CANCEL"); + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.MODE), update); + assertTrue(handler.canHandle(command)); + } + + @Test + void canHandle_whenCommandTypeNull_thenFalse() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, null, update); + assertFalse(handler.canHandle(command)); + } + + @Test + void handleInner_whenMessageNull_thenThrows() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + when(update.getMessage()).thenReturn(null); + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.MODE), update); + assertThrows(TelegramCommandHandlerException.class, () -> handler.handleInner(command)); + } + + @Test + void handleInner_whenPlainCommand_thenSendsCurrentModeAndMenu() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + Message message = mock(Message.class); + User from = mock(User.class); + when(update.getMessage()).thenReturn(message); + when(message.getFrom()).thenReturn(from); + + TelegramUser telegramUser = new TelegramUser(); + telegramUser.setId(1L); + telegramUser.setAgentModeEnabled(true); + when(telegramUserService.getOrCreateUser(from)).thenReturn(telegramUser); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.MODE), update); + command.languageCode("en"); + + handler.handleInner(command); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(SendMessage.class); + verify(telegramBot).execute(messageCaptor.capture()); + SendMessage sentMessage = messageCaptor.getValue(); + assertEquals(CHAT_ID.toString(), sentMessage.getChatId()); + assertTrue(sentMessage.getText().contains("Current mode")); + assertTrue(sentMessage.getText().contains("Choose mode")); + + InlineKeyboardMarkup markup = (InlineKeyboardMarkup) sentMessage.getReplyMarkup(); + assertNotNull(markup); + assertEquals(2, markup.getKeyboard().size()); + assertEquals("MODE_AGENT", markup.getKeyboard().getFirst().get(0).getCallbackData()); + assertEquals("MODE_REGULAR", markup.getKeyboard().getFirst().get(1).getCallbackData()); + assertEquals("MODE_CANCEL", markup.getKeyboard().get(1).getFirst().getCallbackData()); + } + + @Test + void handle_whenPlainCommand_doesNotStartTyping() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + Message message = mock(Message.class); + User from = mock(User.class); + when(update.getMessage()).thenReturn(message); + when(message.getFrom()).thenReturn(from); + + TelegramUser telegramUser = new TelegramUser(); + telegramUser.setId(1L); + telegramUser.setAgentModeEnabled(false); + when(telegramUserService.getOrCreateUser(from)).thenReturn(telegramUser); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.MODE), update); + command.languageCode("en"); + + handler.handle(command); + + verify(typingIndicatorService, never()).startTyping(CHAT_ID); + verify(typingIndicatorService, never()).stopTyping(CHAT_ID); + } + + @Test + void handleInner_whenCallbackAgent_thenUpdatesAgentModeAndClosesMenu() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + User from = mock(User.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("MODE_AGENT"); + when(cq.getFrom()).thenReturn(from); + when(from.getId()).thenReturn(USER_ID); + when(cq.getId()).thenReturn("cq-1"); + Message callbackMessage = mock(Message.class); + when(callbackMessage.getMessageId()).thenReturn(77); + when(cq.getMessage()).thenReturn(callbackMessage); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.MODE), update); + command.languageCode("en"); + + handler.handleInner(command); + + verify(telegramUserService).updateAgentMode(USER_ID, true); + verify(telegramBot).execute(any(org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery.class)); + verify(telegramBot).execute(any(DeleteMessage.class)); + } + + @Test + void handleInner_whenCallbackRegular_thenUpdatesRegularModeAndClosesMenu() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + User from = mock(User.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("MODE_REGULAR"); + when(cq.getFrom()).thenReturn(from); + when(from.getId()).thenReturn(USER_ID); + when(cq.getId()).thenReturn("cq-1"); + Message callbackMessage = mock(Message.class); + when(callbackMessage.getMessageId()).thenReturn(78); + when(cq.getMessage()).thenReturn(callbackMessage); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.MODE), update); + command.languageCode("en"); + + handler.handleInner(command); + + verify(telegramUserService).updateAgentMode(USER_ID, false); + verify(telegramBot).execute(any(org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery.class)); + verify(telegramBot).execute(any(DeleteMessage.class)); + } + + @Test + void handleInner_whenCallbackCancel_thenDeletesMenuWithoutUpdatingMode() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("MODE_CANCEL"); + when(cq.getId()).thenReturn("cq-1"); + Message callbackMessage = mock(Message.class); + when(callbackMessage.getMessageId()).thenReturn(79); + when(cq.getMessage()).thenReturn(callbackMessage); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.MODE), update); + + handler.handleInner(command); + + verify(telegramBot).execute(any(org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery.class)); + verify(telegramBot).execute(any(DeleteMessage.class)); + verify(telegramUserService, never()).updateAgentMode(anyLong(), anyBoolean()); + } + + @Test + void handleInner_whenCallbackUnknown_thenSendsError() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("MODE_UNKNOWN_VALUE"); + when(cq.getFrom()).thenReturn(mock(User.class)); + when(cq.getId()).thenReturn("cq-1"); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.MODE), update); + command.languageCode("en"); + + handler.handleInner(command); + + verify(telegramBot).sendErrorMessage(eq(CHAT_ID), eq("Unknown mode"), isNull()); + } + + @Test + void getSupportedCommandText_returnsLocalizedDesc() { + assertEquals("/mode - switch agent mode", handler.getSupportedCommandText("en")); + } +} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java index b9b09c44..c9f6fcd2 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java @@ -8,8 +8,10 @@ import io.github.ngirchev.opendaimon.common.ai.ModelCapabilities; import io.github.ngirchev.opendaimon.common.ai.command.AICommand; import io.github.ngirchev.opendaimon.common.ai.pipeline.AIRequestPipeline; +import io.github.ngirchev.opendaimon.common.service.AIGateway; import io.github.ngirchev.opendaimon.common.service.AIGatewayRegistry; import io.github.ngirchev.opendaimon.common.service.OpenDaimonMessageService; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; @@ -31,6 +33,7 @@ import reactor.core.publisher.Flux; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -87,7 +90,7 @@ void setUp() { telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, userModelPreferenceService, persistentKeyboardService, replyImageAttachmentService, messageSender, - agentExecutor, agentStreamRenderer, MAX_ITERATIONS); + agentExecutor, agentStreamRenderer, MAX_ITERATIONS, true); } @Test @@ -253,6 +256,39 @@ void generateResponse_chatOnlyCapability_usesSimpleStrategy() { assertThat(ctx.getResponseText()).hasValue("Reply"); } + @Test + @DisplayName("createCommand looks up aiGateway when agentExecutor is present but user disabled agent mode") + void shouldLookupAiGatewayInCreateCommandWhenAgentExecutorPresentButUserDisabledAgentMode() { + // Arrange: agentExecutor is non-null (wired in @BeforeEach), but user has agent mode OFF + TelegramUser telegramUser = new TelegramUser(); + telegramUser.setAgentModeEnabled(Boolean.FALSE); + + TelegramCommand command = mock(TelegramCommand.class); + MessageHandlerContext ctx = new MessageHandlerContext(command, null, s -> {}); + ctx.setTelegramUser(telegramUser); + + Map metadata = new HashMap<>(); + metadata.put(AICommand.THREAD_KEY_FIELD, "test-thread-key"); + ctx.setMetadata(metadata); + + AICommand aiCommand = mock(AICommand.class); + when(aiCommand.modelCapabilities()).thenReturn(Set.of(ModelCapabilities.CHAT)); + when(aiRequestPipeline.prepareCommand(any(), any())).thenReturn(aiCommand); + + AIGateway aiGateway = mock(AIGateway.class); + when(aiGatewayRegistry.getSupportedAiGateways(any())).thenReturn(List.of(aiGateway)); + + // Act + actions.createCommand(ctx); + + // Assert: gateway must be populated even though agentExecutor bean is present + assertThat(ctx.getAiGateway()).isNotNull(); + assertThat(ctx.getAiGateway()).isEqualTo(aiGateway); + verify(aiGatewayRegistry).getSupportedAiGateways(any()); + // The agent executor must not be invoked — the predicate routes to the gateway path + verify(agentExecutor, never()).executeStream(any()); + } + // ── Two-message orchestration tests ────────────────────────────── // // The agent run now renders to two separate Telegram messages: diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java index 481b71cd..2508bd08 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java @@ -91,7 +91,7 @@ void setUp() { telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, userModelPreferenceService, persistentKeyboardService, replyImageAttachmentService, messageSender, - agentExecutor, agentStreamRenderer, MAX_ITERATIONS); + agentExecutor, agentStreamRenderer, MAX_ITERATIONS, true); } @Test diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java index 29bc21e6..007d3a54 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java @@ -90,7 +90,7 @@ void setUp() { telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, userModelPreferenceService, persistentKeyboardService, replyImageAttachmentService, messageSender, - agentExecutor, agentStreamRenderer, MAX_ITERATIONS); + agentExecutor, agentStreamRenderer, MAX_ITERATIONS, true); } @Test diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserServiceTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserServiceTest.java index e7539309..c0b97ea8 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserServiceTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserServiceTest.java @@ -14,6 +14,7 @@ import java.time.OffsetDateTime; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -35,7 +36,7 @@ class TelegramUserServiceTest { @BeforeEach void setUp() { - userService = new TelegramUserService(userRepository, telegramUserSessionService, assistantRoleService); + userService = new TelegramUserService(userRepository, telegramUserSessionService, assistantRoleService, false); } @Test @@ -198,5 +199,77 @@ void whenUpdateAssistantRole_thenUpdatesRoleAndSavesUser() { verify(assistantRoleService).updateActiveRole(any(TelegramUser.class), any()); verify(userRepository).save(user); } + + @Test + void shouldSetDefaultAgentModeWhenCreateNewUser() { + when(telegramUserApi.getId()).thenReturn(200L); + when(telegramUserApi.getUserName()).thenReturn("newuser"); + when(telegramUserApi.getFirstName()).thenReturn("New"); + when(telegramUserApi.getLastName()).thenReturn("User"); + when(telegramUserApi.getLanguageCode()).thenReturn("en"); + when(telegramUserApi.getIsPremium()).thenReturn(false); + when(userRepository.findByTelegramId(200L)).thenReturn(Optional.empty()); + when(userRepository.save(any(TelegramUser.class))).thenAnswer(inv -> inv.getArgument(0)); + + TelegramUserService serviceWithAgentEnabled = + new TelegramUserService(userRepository, telegramUserSessionService, assistantRoleService, true); + TelegramUser result = serviceWithAgentEnabled.getOrCreateUser(telegramUserApi); + + assertThat(result.getAgentModeEnabled()).isTrue(); + + reset(userRepository); + when(userRepository.findByTelegramId(200L)).thenReturn(Optional.empty()); + when(userRepository.save(any(TelegramUser.class))).thenAnswer(inv -> inv.getArgument(0)); + + TelegramUserService serviceWithAgentDisabled = + new TelegramUserService(userRepository, telegramUserSessionService, assistantRoleService, false); + TelegramUser resultDisabled = serviceWithAgentDisabled.getOrCreateUser(telegramUserApi); + + assertThat(resultDisabled.getAgentModeEnabled()).isFalse(); + } + + @Test + void shouldUpdateAgentModeWhenCalled() { + TelegramUser user = new TelegramUser(); + user.setId(1L); + user.setTelegramId(300L); + user.setAgentModeEnabled(false); + OffsetDateTime before = OffsetDateTime.now().minusSeconds(5); + user.setUpdatedAt(before); + user.setLastActivityAt(before); + + when(userRepository.findByTelegramId(300L)).thenReturn(Optional.of(user)); + when(userRepository.save(any(TelegramUser.class))).thenAnswer(inv -> inv.getArgument(0)); + + userService.updateAgentMode(300L, true); + + assertThat(user.getAgentModeEnabled()).isTrue(); + assertThat(user.getUpdatedAt()).isAfter(before); + assertThat(user.getLastActivityAt()).isAfter(before); + verify(userRepository).save(user); + } + + @Test + void shouldPreserveAgentModeWhenRefreshExistingUser() { + when(telegramUserApi.getId()).thenReturn(400L); + when(telegramUserApi.getUserName()).thenReturn("existinguser"); + when(telegramUserApi.getFirstName()).thenReturn("Existing"); + when(telegramUserApi.getIsPremium()).thenReturn(false); + + TelegramUser existing = new TelegramUser(); + existing.setTelegramId(400L); + existing.setAgentModeEnabled(false); + existing.setLanguageCode("ru"); + + when(userRepository.findByTelegramId(400L)).thenReturn(Optional.of(existing)); + when(userRepository.save(any(TelegramUser.class))).thenAnswer(inv -> inv.getArgument(0)); + + TelegramUserService serviceWithAgentEnabled = + new TelegramUserService(userRepository, telegramUserSessionService, assistantRoleService, true); + TelegramUser result = serviceWithAgentEnabled.getOrCreateUser(telegramUserApi); + + // Existing user's agentModeEnabled must NOT be overwritten by the application default + assertThat(result.getAgentModeEnabled()).isFalse(); + } } \ No newline at end of file From 5ff8c2a45a3034abc16ad71a0d2902d16c19c95b Mon Sep 17 00:00:00 2001 From: ngirchev Date: Mon, 20 Apr 2026 23:06:12 +0300 Subject: [PATCH 02/14] reverted logs --- .../GatewayPassthroughOpenRouterManualIT.java | 424 ++++++++++++++++++ .../ai/springai/agent/ReActAgentExecutor.java | 12 +- .../agent/SpringAgentLoopActions.java | 6 - .../StrategyDelegatingAgentExecutor.java | 8 +- .../fsm/TelegramMessageHandlerActions.java | 2 +- .../telegrambots/meta/api/objects/User.java | 100 +++++ 6 files changed, 527 insertions(+), 25 deletions(-) create mode 100644 opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GatewayPassthroughOpenRouterManualIT.java create mode 100644 org/telegram/telegrambots/meta/api/objects/User.java diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GatewayPassthroughOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GatewayPassthroughOpenRouterManualIT.java new file mode 100644 index 00000000..35221ef7 --- /dev/null +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GatewayPassthroughOpenRouterManualIT.java @@ -0,0 +1,424 @@ +package io.github.ngirchev.opendaimon.it.manual; + +import io.github.ngirchev.dotenv.DotEnvLoader; +import io.github.ngirchev.opendaimon.ai.springai.tool.WebTools; +import io.github.ngirchev.opendaimon.bulkhead.service.IWhitelistService; +import io.github.ngirchev.opendaimon.common.model.ConversationThread; +import io.github.ngirchev.opendaimon.common.model.MessageRole; +import io.github.ngirchev.opendaimon.common.model.OpenDaimonMessage; +import io.github.ngirchev.opendaimon.common.repository.ConversationThreadRepository; +import io.github.ngirchev.opendaimon.common.repository.OpenDaimonMessageRepository; +import io.github.ngirchev.opendaimon.telegram.TelegramBot; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.MessageTelegramCommandHandler; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserRepository; +import io.github.ngirchev.opendaimon.telegram.service.TelegramBotRegistrar; +import io.github.ngirchev.opendaimon.test.AbstractContainerIT; +import lombok.extern.slf4j.Slf4j; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.reactive.function.client.WebClient; +import org.telegram.telegrambots.meta.api.objects.Chat; +import org.telegram.telegrambots.meta.api.objects.Message; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboard; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.reset; + +/** + * Manual E2E regression test for gateway (passthrough, non-agent) path with + * {@code z-ai/glm-4.5v} via OpenRouter. + * + *

Reproduces the production bug where the model, in combination with + * {@code extra_body.reasoning.max_tokens} being sent by the gateway, emits: + *

    + *
  1. A {@code web_search} tool call with empty arguments → Spring AI + * calls {@code WebTools.webSearch(null)} → returns empty result → no real search.
  2. + *
  3. The final text answer contains reasoning preamble leaked from the thinking + * channel (e.g. "Я помогу вам найти… мне нужно выполнить поиск").
  4. + *
+ * + *

Uses {@link MockWebServer} for the Serper API — only {@code OPENROUTER_KEY} + * is required (no {@code SERPER_KEY} needed). A {@link SpyWebTools} wrapper records + * every {@code webSearch} invocation so assertions can inspect the query arguments. + * + *

The test MUST FAIL with the current code (reproducing the bug). After applying the + * fix (disabling reasoning budget for {@code z-ai/glm-4.5v} in {@code application.yml}), + * the test MUST PASS. + * + *

Run: + *

+ * ./mvnw -pl opendaimon-app -am test-compile failsafe:integration-test failsafe:verify \
+ *   -Dit.test=GatewayPassthroughOpenRouterManualIT \
+ *   -Dfailsafe.failIfNoSpecifiedTests=false \
+ *   -Dmanual.openrouter.e2e=true
+ * 
+ */ +@Tag("manual") +@EnabledIfSystemProperty(named = "manual.openrouter.e2e", matches = "true") +@SpringBootTest( + classes = GatewayPassthroughOpenRouterManualIT.TestConfig.class, + properties = { + "open-daimon.agent.enabled=false", + // Allow VIP users to access paid models like z-ai/glm-4.5v. + // The integration-test profile caps VIP at $0.50, which may exclude + // glm-4.5v. Raise to $5.0 so the model selector can pick it. + "open-daimon.common.chat-routing.VIP.max-price=5.0" + } +) +@ActiveProfiles({"integration-test", "manual-openrouter-real-tools"}) +@Slf4j +class GatewayPassthroughOpenRouterManualIT extends AbstractContainerIT { + + static { + DotEnvLoader.loadDotEnv(Path.of("../.env")); + } + + // Use an ID that is in telegram.access.ADMIN.ids of application-manual-openrouter.yaml + // so TelegramUserPriorityService resolves the test user to ADMIN tier — matching the + // real-prod scenario (admin with preferred model z-ai/glm-4.5v) that exhibits the bug. + private static final Long TEST_CHAT_ID = 350009004L; + + /** Fake Serper search response — realistic enough for the model to produce an answer. */ + private static final String SERPER_RESPONSE_JSON = """ + { + "organic": [ + { + "title": "Cyprus theatres 2026 — season schedule", + "link": "https://www.theatrescu.org/season/2026", + "snippet": "The Limassol Municipal Theatre presents three Russian-language productions in April 2026." + }, + { + "title": "Russian drama in Cyprus — upcoming events", + "link": "https://ru.cyprusevents.com/drama/2026", + "snippet": "Russian-speaking theatre community in Cyprus announces upcoming shows in April and May 2026." + } + ] + } + """; + + // Started eagerly so TestConfig can read the port during context initialization. + private static final MockWebServer mockWebServer = createMockWebServer(); + + @Autowired + private MessageTelegramCommandHandler messageHandler; + + @Autowired + private TelegramUserRepository telegramUserRepository; + + @Autowired + private ConversationThreadRepository threadRepository; + + @Autowired + private OpenDaimonMessageRepository messageRepository; + + @Autowired + private SpyWebTools spyWebTools; + + @Autowired + private io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService userModelPreferenceService; + + @MockitoBean + private TelegramBotRegistrar telegramBotRegistrar; + + @MockitoBean + private TelegramBot telegramBot; + + @BeforeAll + static void requireOpenRouterKey() { + DotEnvLoader.loadDotEnv(Path.of("../.env")); + String openRouterKey = System.getProperty("OPENROUTER_KEY", System.getenv("OPENROUTER_KEY")); + Assumptions.assumeTrue( + openRouterKey != null && !openRouterKey.isBlank() && !openRouterKey.equals("sk-placeholder"), + "Skipping manual test: OPENROUTER_KEY not set in .env or environment" + ); + } + + @AfterAll + static void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + @BeforeEach + void setUpEach() throws TelegramApiException { + messageRepository.deleteAll(); + threadRepository.deleteAll(); + telegramUserRepository.deleteAll(); + spyWebTools.clearCapturedQueries(); + + // Pre-create the test user. TEST_CHAT_ID is in telegram.access.ADMIN.ids + // (application-manual-openrouter.yaml) so TelegramUserPriorityService resolves + // this user to ADMIN tier. Additionally, set preferred model so the factory + // routes through FixedModelChatAICommand (with the model's own caps = WEB, + // TOOL_CALLING), matching the real-prod path where webEnabled=true. + TelegramUser adminUser = new TelegramUser(); + adminUser.setTelegramId(TEST_CHAT_ID); + adminUser.setUsername("gateway-passthrough-user-" + TEST_CHAT_ID); + adminUser.setFirstName("Gateway"); + adminUser.setLastName("Passthrough"); + adminUser.setLanguageCode("ru"); + adminUser.setIsAdmin(true); + adminUser.setIsPremium(true); + adminUser.setIsBlocked(false); + adminUser.setCreatedAt(java.time.OffsetDateTime.now()); + adminUser.setUpdatedAt(java.time.OffsetDateTime.now()); + adminUser.setLastActivityAt(java.time.OffsetDateTime.now()); + TelegramUser savedUser = telegramUserRepository.save(adminUser); + + // Pin z-ai/glm-4.5v as the preferred model — this is the real-prod path that + // triggers the empty-tool_call bug (FixedModelChatAICommand with caps=WEB). + userModelPreferenceService.setPreferredModel(savedUser.getId(), "z-ai/glm-4.5v"); + + reset(telegramBot); + doNothing().when(telegramBot).showTyping(anyLong()); + doNothing().when(telegramBot).sendMessage(anyLong(), anyString(), any(), any(ReplyKeyboard.class)); + doNothing().when(telegramBot).sendMessage(anyLong(), anyString(), any()); + doNothing().when(telegramBot).sendErrorMessage(anyLong(), anyString(), any()); + } + + // ── W1: gateway passthrough — web_search called with non-empty query ──── + + /** + * W1: When the user asks a question that requires web search (user is REGULAR, + * model has WEB capability), the gateway must pass a non-blank query to + * {@code WebTools.webSearch}. + * + *

Before the fix: {@code z-ai/glm-4.5v} emits {@code web_search({})} with + * empty args (because the reasoning budget causes it to emit a structural tool + * call before forming the query). Spring AI calls {@code webSearch(null)}. + * The captured query list contains only {@code null} or blank entries → assertion FAILS. + * + *

After the fix ({@code max-reasoning-tokens: 0} on the model config): + * the model emits a proper {@code web_search("Какие спектакли…")} call. + * The captured queries contain at least one non-blank entry → assertion PASSES. + */ + @Test + @Timeout(3 * 60) + @DisplayName("W1: gateway passthrough — web_search invoked with non-empty query for current-events prompt") + void shouldCallWebSearchWithNonEmptyQueryWhenAskedForCurrentEvents() { + TelegramCommand command = createMessageCommand( + TEST_CHAT_ID, + 1, + "Какие спектакли на русском языке будут на Кипре в ближайшее время" + ); + + messageHandler.handle(command); + + // Log all captured web_search invocations for diagnostics + List capturedQueries = spyWebTools.getCapturedQueries(); + log.info("W1: captured webSearch queries ({}): {}", capturedQueries.size(), capturedQueries); + + TelegramUser user = telegramUserRepository.findByTelegramId(TEST_CHAT_ID) + .orElseThrow(() -> new IllegalStateException("Telegram user should be created")); + + ConversationThread thread = threadRepository.findMostRecentActiveThread(user) + .orElseThrow(() -> new IllegalStateException("Active thread should exist")); + + String finalReply = latestAssistantReply(thread); + log.info("W1: final reply ({}): {}", finalReply.length(), finalReply); + + // Primary assertion: at least one webSearch call with a non-blank query. + // Fails before the fix (all captured queries are null/blank). + assertThat(capturedQueries) + .as("Gateway must invoke webSearch with at least one non-blank query. " + + "Captured queries: " + capturedQueries + ". " + + "Likely cause: model emitting empty tool_call args due to reasoning budget leak.") + .anyMatch(q -> q != null && !q.isBlank()); + + // Secondary assertion: final answer must not contain reasoning preamble + // leaked from the thinking channel into main text. + assertThat(finalReply) + .as("Final answer must not contain reasoning preamble leaked from the thinking channel") + .doesNotContainIgnoringCase("Я помогу вам найти") + .doesNotContainIgnoringCase("I will help you find") + .doesNotContainIgnoringCase("мне нужно выполнить поиск") + .doesNotContainIgnoringCase("need to perform a search"); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private TelegramCommand createMessageCommand(Long chatId, int messageId, String text) { + Update update = new Update(); + + User from = new User(); + from.setId(chatId); + from.setUserName("gateway-passthrough-user-" + chatId); + from.setFirstName("Gateway"); + from.setLastName("Passthrough"); + from.setLanguageCode("ru"); + + Message message = new Message(); + message.setMessageId(messageId); + Chat chat = new Chat(); + chat.setId(chatId); + message.setChat(chat); + message.setFrom(from); + message.setText(text); + update.setMessage(message); + + TelegramCommand command = new TelegramCommand( + null, + chatId, + new TelegramCommandType(TelegramCommand.MESSAGE), + update, + text, + false, + List.of() + ); + command.languageCode("ru"); + return command; + } + + private String latestAssistantReply(ConversationThread thread) { + List assistantMessages = messageRepository + .findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.ASSISTANT); + assertThat(assistantMessages) + .as("Assistant message should be saved to DB") + .isNotEmpty(); + return assistantMessages.getLast().getContent(); + } + + // ── MockWebServer ───────────────────────────────────────────────────────── + + private static MockWebServer createMockWebServer() { + MockWebServer server = new MockWebServer(); + server.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + // POST → Serper web_search endpoint + return new MockResponse() + .setBody(SERPER_RESPONSE_JSON) + .addHeader("Content-Type", "application/json"); + } + }); + try { + server.start(); + } catch (IOException e) { + throw new RuntimeException("Failed to start MockWebServer for Serper", e); + } + return server; + } + + // ── Spring Boot test configuration ─────────────────────────────────────── + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestConfig { + + /** + * Allow-all whitelist so the FSM does not try to call Telegram's + * {@code getChatMember} API on a mocked bot (which would NPE). + * The whitelist check is irrelevant to the gateway/reasoning-budget bug + * this test is covering. + */ + @Bean + @Primary + public IWhitelistService allowAllWhitelistService() { + return new IWhitelistService() { + @Override + public boolean isUserAllowed(Long userId) { + return true; + } + + @Override + public boolean checkUserInChannel(Long userId) { + return true; + } + + @Override + public boolean checkUserInChannel(Long userId, String channelId) { + return true; + } + + @Override + public void addToWhitelist(Long userId) { + // no-op in test + } + }; + } + + /** + * {@link SpyWebTools} replaces the production {@link WebTools} bean. + * Delegates all method calls to the real implementation but records + * every {@code webSearch} query for post-call assertion. + * Points the Serper URL at the local {@link MockWebServer} so no real + * Serper API key is required for this test. + */ + @Bean + @Primary + public SpyWebTools webTools() { + String mockBaseUrl = "http://localhost:" + mockWebServer.getPort(); + WebClient webClient = WebClient.builder().build(); + return new SpyWebTools(webClient, "fake-serper-key", mockBaseUrl + "/search"); + } + } + + // ── SpyWebTools ─────────────────────────────────────────────────────────── + + /** + * Instrumented subclass of {@link WebTools} that records every {@code webSearch} + * query argument for test assertions. + * + *

This is the primary observable for the bug: before the fix, all captured + * queries are {@code null} or blank (empty tool_call args from {@code z-ai/glm-4.5v}). + * After the fix, at least one captured query is non-blank. + */ + static class SpyWebTools extends WebTools { + + private final CopyOnWriteArrayList capturedQueries = new CopyOnWriteArrayList<>(); + + public SpyWebTools(WebClient webClient, String apiKey, String apiUrl) { + super(webClient, apiKey, apiUrl); + } + + @Override + public SearchResult webSearch(String query) { + capturedQueries.add(query); + log.info("SpyWebTools.webSearch captured query=[{}]", query); + return super.webSearch(query); + } + + public List getCapturedQueries() { + // List.copyOf throws NPE on null elements; use ArrayList copy to preserve nulls + // (null entries represent the buggy empty tool_call args from the model). + return new java.util.ArrayList<>(capturedQueries); + } + + public void clearCapturedQueries() { + capturedQueries.clear(); + } + } +} diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java index d1c5befe..e09e2d26 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java @@ -60,16 +60,11 @@ public AgentResult execute(AgentRequest request) { @Override public Flux executeStream(AgentRequest request) { Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - log.info("AGENT_TRACE: sink created"); - Flux eventFlux = sink.asFlux() - .doOnSubscribe(s -> log.info("AGENT_TRACE: eventFlux subscribed")) - .doOnCancel(() -> log.info("AGENT_TRACE: eventFlux cancelled")) - .doOnError(e -> log.info("AGENT_TRACE: eventFlux errored: {}", e.toString())); + Flux eventFlux = sink.asFlux(); // Run FSM in a bounded elastic thread to avoid blocking the caller Flux.defer(() -> { - log.info("AGENT_TRACE: defer body started on thread={}", Thread.currentThread().getName()); try { AgentContext ctx = new AgentContext( request.task(), @@ -78,17 +73,14 @@ public Flux executeStream(AgentRequest request) { request.maxIterations(), request.enabledTools() ); - log.info("AGENT_TRACE: AgentContext constructed, conversationId={}", request.conversationId()); // Install an event listener on the context ctx.setStreamSink(sink::tryEmitNext); - log.info("AGENT_TRACE: agentFsm.handle START called"); agentFsm.handle(ctx, AgentEvent.START); // Emit metadata (model name) before terminal event AgentResult result = ctx.toResult(); - log.info("AGENT_TRACE: agentFsm.handle START returned, result.terminalState={}", result.terminalState()); if (result.modelName() != null) { sink.tryEmitNext(AgentStreamEvent.metadata( result.modelName(), result.iterationsUsed())); @@ -126,9 +118,7 @@ public Flux executeStream(AgentRequest request) { } sink.tryEmitComplete(); - log.info("AGENT_TRACE: sink tryEmitComplete called"); } catch (Exception e) { - log.info("AGENT_TRACE: defer body threw {}: {}", e.getClass().getName(), e.getMessage()); log.error("Agent stream execution failed: {}", e.getMessage(), e); sink.tryEmitNext(AgentStreamEvent.error(e.getMessage(), 0)); sink.tryEmitError(e); diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java index 80e14ae7..36e6ca53 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java @@ -138,8 +138,6 @@ public SpringAgentLoopActions(ChatModel chatModel, @Override public void think(AgentContext ctx) { - log.info("AGENT_TRACE: SpringAgentLoopActions.think entered, iteration={}, thread={}", - ctx.getCurrentIteration(), Thread.currentThread().getName()); if (ctx.isCancelled()) { ctx.setErrorMessage("Agent run cancelled by user before think()"); return; @@ -177,8 +175,6 @@ public void think(AgentContext ctx) { .collect(Collectors.joining("\n---\n"))); } - log.info("AGENT_TRACE: calling LLM model={}, iteration={}", - preferredModelId != null ? preferredModelId : "default", ctx.getCurrentIteration()); ChatResponse response = streamAndAggregate(ctx, prompt); if (response == null) { ctx.setErrorMessage("LLM returned an empty stream"); @@ -418,7 +414,6 @@ public void executeTool(AgentContext ctx) { } log.info("Agent executeTool: tool={}", ctx.getCurrentToolName()); - log.info("AGENT_TRACE: invoking tool {}", ctx.getCurrentToolName()); ToolExecutionResult toolResult = toolCallingManager.executeToolCalls(prompt, response); @@ -765,7 +760,6 @@ private void executeFallbackToolCall(AgentContext ctx) { } log.info("Agent executeTool (fallback): tool={}, args={}", toolName, toolArgs); - log.info("AGENT_TRACE: invoking tool {} (fallback path)", toolName); String result = guardFetchUrlCallback(ctx, callback).call(toolArgs); ctx.setToolResult(AgentToolResult.success(toolName, result)); diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/StrategyDelegatingAgentExecutor.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/StrategyDelegatingAgentExecutor.java index 1668de39..337929eb 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/StrategyDelegatingAgentExecutor.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/StrategyDelegatingAgentExecutor.java @@ -55,17 +55,11 @@ public AgentResult execute(AgentRequest request) { public Flux executeStream(AgentRequest request) { AgentStrategy strategy = resolveStrategy(request); log.info("Agent stream strategy resolved: requested={}, resolved={}", request.strategy(), strategy); - log.info("AGENT_TRACE: StrategyDelegatingAgentExecutor.executeStream entered, strategy={}", strategy); return switch (strategy) { case SIMPLE -> simpleExecutor.executeStream(request); case PLAN_AND_EXECUTE -> planAndExecuteExecutor.executeStream(request); - case REACT, AUTO -> { - log.info("AGENT_TRACE: delegating to reactExecutor"); - Flux flux = reactExecutor.executeStream(request); - log.info("AGENT_TRACE: reactExecutor returned Flux"); - yield flux; - } + case REACT, AUTO -> reactExecutor.executeStream(request); }; } diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java index e0854da1..a8109322 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java @@ -360,7 +360,7 @@ private void generateAgentResponse(MessageHandlerContext ctx) { handleStreamError(ctx, err); return reactor.core.publisher.Flux.empty(); }) - .blockLast(java.time.Duration.ofSeconds(90)); + .blockLast(); finalizeAfterStream(ctx, lastEvent); diff --git a/org/telegram/telegrambots/meta/api/objects/User.java b/org/telegram/telegrambots/meta/api/objects/User.java new file mode 100644 index 00000000..54c80520 --- /dev/null +++ b/org/telegram/telegrambots/meta/api/objects/User.java @@ -0,0 +1,100 @@ +package org.telegram.telegrambots.meta.api.objects; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.telegram.telegrambots.meta.api.interfaces.BotApiObject; + +/** + * This object represents a Telegram user or bot. + * @author Ruben Bermudez + * @version 6.1 + */ +@EqualsAndHashCode(callSuper = false) +@Getter +@Setter +@ToString +@NoArgsConstructor(force = true) +@AllArgsConstructor +@RequiredArgsConstructor +public class User implements BotApiObject { + private static final String ID_FIELD = "id"; + private static final String FIRSTNAME_FIELD = "first_name"; + private static final String ISBOT_FIELD = "is_bot"; + private static final String LASTNAME_FIELD = "last_name"; + private static final String USERNAME_FIELD = "username"; + private static final String LANGUAGECODE_FIELD = "language_code"; + private static final String CANJOINGROUPS_FIELD = "can_join_groups"; + private static final String CANREADALLGROUPMESSAGES_FIELD = "can_read_all_group_messages"; + private static final String SUPPORTINLINEQUERIES_FIELD = "supports_inline_queries"; + private static final String ISPREMIUM_FIELD = "is_premium"; + private static final String ADDEDTOATTACHMENTMENU_FIELD = "added_to_attachment_menu"; + + /** + * Unique identifier for this user or bot. + * + * @apiNote This number may have more than 32 significant bits and some programming languages may have difficulty/silent defects in interpreting it. + * But it has at most 52 significant bits, so a 64-bit integer or double-precision float type are safe for storing this identifier. + */ + @JsonProperty(ID_FIELD) + @NonNull + private Long id; + /** + * User‘s or bot’s first name + */ + @JsonProperty(FIRSTNAME_FIELD) + @NonNull + private String firstName; + /** + * True, if this user is a bot + */ + @JsonProperty(ISBOT_FIELD) + @NonNull + private Boolean isBot; + /** + * Optional. User‘s or bot’s last name + */ + @JsonProperty(LASTNAME_FIELD) + private String lastName; + /** + * Optional. User‘s or bot’s username + */ + @JsonProperty(USERNAME_FIELD) + private String userName; + /** + * Optional. IETF language tag of the user's language + */ + @JsonProperty(LANGUAGECODE_FIELD) + private String languageCode; + /** + * Optional. True, if the bot can be invited to groups. Returned only in getMe. + */ + @JsonProperty(CANJOINGROUPS_FIELD) + private Boolean canJoinGroups; + /** + * Optional. True, if privacy mode is disabled for the bot. Returned only in getMe. + */ + @JsonProperty(CANREADALLGROUPMESSAGES_FIELD) + private Boolean canReadAllGroupMessages; + /** + * Optional. True, if the bot supports inline queries. Returned only in getMe. + */ + @JsonProperty(SUPPORTINLINEQUERIES_FIELD) + private Boolean supportInlineQueries; + /** + * Optional. True, if this user is a Telegram Premium user + */ + @JsonProperty(ISPREMIUM_FIELD) + private Boolean isPremium; + /** + * Optional. True, if this user added the bot to the attachment menu + */ + @JsonProperty(ADDEDTOATTACHMENTMENU_FIELD) + private Boolean addedToAttachmentMenu; +} From 842dfcce390af4d903c719a415f049e7f13b4411 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Tue, 21 Apr 2026 00:01:37 +0300 Subject: [PATCH 03/14] Changed mode --- .../ai/springai/service/SpringAIGateway.java | 31 ++++++++++++++++++- .../impl/ModeTelegramCommandHandler.java | 15 +++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIGateway.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIGateway.java index 6b76d9a6..e1c455a6 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIGateway.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIGateway.java @@ -361,7 +361,8 @@ private AIResponse createMockResponse() { private void addSystemAndUserMessagesIfNeeded(List messages, OpenDaimonChatOptions chatOptions, AICommand command) { if (StringUtils.hasText(chatOptions.systemRole())) { - String systemRole = appendLanguageInstruction(chatOptions.systemRole(), command); + String systemRole = appendToolCallingInstruction( + appendLanguageInstruction(chatOptions.systemRole(), command), command); boolean alreadyPresent = messages.stream() .filter(SystemMessage.class::isInstance) .map(SystemMessage.class::cast) @@ -471,6 +472,34 @@ private String appendLanguageInstruction(String systemRole, AICommand command) { return systemRole + "\nPrefer responding in " + languageName + " (" + languageCode + "). When quoting text from documents or context, preserve the original language exactly."; } + /** + * Adds a tool-calling discipline instruction to the system prompt when the command + * routes through a tool-capable tier. Mitigates model quirk where the LLM emits a + * tool_call with empty/null arguments mid-stream (observed for z-ai/glm-4.5v via + * OpenRouter under reasoning mode). Applied for ALL models that have WEB or + * TOOL_CALLING in their required or optional capabilities — universal guard, no + * per-model branching. + */ + private String appendToolCallingInstruction(String systemRole, AICommand command) { + if (command == null) { + return systemRole; + } + boolean toolCapable = + command.modelCapabilities().contains(ModelCapabilities.WEB) + || command.modelCapabilities().contains(ModelCapabilities.TOOL_CALLING) + || command.optionalCapabilities().contains(ModelCapabilities.WEB) + || command.optionalCapabilities().contains(ModelCapabilities.TOOL_CALLING); + if (!toolCapable) { + return systemRole; + } + return systemRole + + "\nWhen calling any tool, you MUST provide all required parameters" + + " with concrete non-empty values. Never emit a tool call with empty" + + " or null arguments. For web_search, always include a non-empty" + + " `query` string describing what to search. For fetch_url, always" + + " include a valid http(s) `url`."; + } + private UserPriority resolveUserPriority(AICommand command) { if (command == null || command.metadata() == null) { return null; diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java index 2a2ebf52..ee6c427a 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java @@ -100,6 +100,7 @@ private void handleCallbackQuery(TelegramCommand command) { String updatedMsg = messageLocalizationService.getMessage("telegram.mode.updated", command.languageCode(), label); ackCallback(cq.getId(), updatedMsg); deleteMenuMessage(command.telegramId(), cq); + sendConfirmationMessage(command.telegramId(), updatedMsg); return; } if (CALLBACK_REGULAR.equals(callbackData)) { @@ -108,6 +109,7 @@ private void handleCallbackQuery(TelegramCommand command) { String updatedMsg = messageLocalizationService.getMessage("telegram.mode.updated", command.languageCode(), label); ackCallback(cq.getId(), updatedMsg); deleteMenuMessage(command.telegramId(), cq); + sendConfirmationMessage(command.telegramId(), updatedMsg); return; } ackCallback(cq.getId(), "❌"); @@ -161,6 +163,19 @@ private void ackCallback(String callbackQueryId, String text) { } } + /** + * Posts a persistent confirmation message into the chat so the user sees the + * selected mode in conversation history (not just as a transient toast). + */ + private void sendConfirmationMessage(Long chatId, String text) { + try { + SendMessage msg = new SendMessage(chatId.toString(), text); + telegramBotProvider.getObject().execute(msg); + } catch (Exception e) { + log.warn("Failed to send mode confirmation message: {}", e.getMessage()); + } + } + private void deleteMenuMessage(Long chatId, CallbackQuery callbackQuery) { if (callbackQuery.getMessage() instanceof Message menuMessage) { try { From 50aee80dfce5abb20380720424c83fd0386576eb Mon Sep 17 00:00:00 2001 From: ngirchev Date: Tue, 21 Apr 2026 20:34:42 +0300 Subject: [PATCH 04/14] Small fixed --- docs/agent-evolution-roadmap.md | 827 ++++++++++++++++++ .../manual/AgentModeOpenRouterManualIT.java | 40 +- .../common/ai/lang/LanguageInstructions.java | 34 + .../ai/lang/LanguageInstructionsTest.java | 64 ++ opendaimon-spring-ai/SPRING_AI_MODULE.md | 11 + .../ai/springai/agent/AgentPromptBuilder.java | 36 +- .../agent/SpringAgentLoopActions.java | 2 +- .../springai/agent/SummaryModelInvoker.java | 15 +- .../ai/springai/service/SpringAIGateway.java | 19 +- .../agent/AgentPromptBuilderTest.java | 39 + .../opendaimon/telegram/TelegramBot.java | 2 + .../impl/LanguageTelegramCommandHandler.java | 14 + 12 files changed, 1073 insertions(+), 30 deletions(-) create mode 100644 docs/agent-evolution-roadmap.md create mode 100644 opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/lang/LanguageInstructions.java create mode 100644 opendaimon-common/src/test/java/io/github/ngirchev/opendaimon/common/ai/lang/LanguageInstructionsTest.java create mode 100644 opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilderTest.java diff --git a/docs/agent-evolution-roadmap.md b/docs/agent-evolution-roadmap.md new file mode 100644 index 00000000..2cae7bc0 --- /dev/null +++ b/docs/agent-evolution-roadmap.md @@ -0,0 +1,827 @@ +# Agent Evolution Roadmap — open-daimon ReAct → Claude-grade + +## Context + +The project currently runs a text-parsing ReAct loop in `opendaimon-spring-ai` +(`SpringAgentLoopActions` + `AgentPromptBuilder` + FSM-driven executor). The user +wants to evolve the agent toward Claude-level capability, keeping **OpenRouter as +the primary transport** and using **Anthropic's native tool-use + extended +thinking + prompt caching** as the reference architecture (Anthropic is reached +*through* OpenRouter, not directly). + +### Design philosophy — controlled flow over delegation + +Claude Code delegates orchestration to the model: one model, one loop, trust +the model to plan / reflect / choose tools. This works for Anthropic because +(a) they own the model and pay marginal cost, (b) their queries are open-ended. + +open-daimon's economics are the opposite: **rented models across OpenRouter, +token costs paid per call, a dominant query pattern (web search)**. Under these +constraints *controlled flow with explicit stages* — router picks tier, +worker runs ReAct, summariser compacts — beats full delegation on cost and +predictability without sacrificing quality on the head of the distribution. +This is not a workaround for a weaker architecture; it is the rational choice +given the constraints. The roadmap below is built around this philosophy: +**keep the FSM, enrich its inputs, split the work across cheaper models where +possible, let the expensive model think only where it matters.** + +This document: + +1. Summarises the current agent (what exists, file-exact), +2. Contrasts it with Claude's native tool-use architecture, +3. Lays out a **prioritised roadmap of 8 steps**, +4. Details steps 1–5 (highest ROI, must-have), +5. Outlines steps 6–8 (nice-to-have, risk/reward discussed), +6. Defines verification. + +Nothing here requires an immediate decision — it is a living roadmap. Each step +is independently shippable. Steps are ordered so that earlier work does not +block later work. + +--- + +## 1. Current architecture (factual reference) + +### 1.1 Loop + +One iteration of `ReActAgentExecutor` drives an `ExDomainFsm` through: + +``` +THINKING → TOOL_EXECUTING → OBSERVING → THINKING … + └→ ANSWERING (final text) + └→ MAX_ITERATIONS (budget hit) + └→ FAILED (error / cancellation) +``` + +Implementation: `SpringAgentLoopActions.java:140–466`, +`ReActAgentExecutor.java:51–118`. + +### 1.2 Prompt composition + +`AgentPromptBuilder.java:19–117` assembles system prompt from: + +- Static ReAct instructions (lines 24–41) — "think, call tool, observe, repeat" +- Static tool-calling discipline (lines 43–48) +- Dynamic language hint (lines 67–75) when `LANGUAGE_CODE_FIELD` present +- User task (first iteration) +- Flattened step history (subsequent iterations, lines 84–116) +- `ChatMemory`-loaded conversation history (prepended in + `SpringAgentLoopActions.java:790–812`) + +No prompt caching markers; the full system prompt is re-sent on every turn. + +### 1.3 Tool layer + +- `@Tool`-annotated methods on `WebTools` / `HttpApiTool` + (`WebTools.java:51–100`) +- Registered as `agentToolCallbacks` in `AgentAutoConfig.java:159–181` +- Extracted via Spring AI `ToolCallingManager` + (`SpringAgentLoopActions.java:395–440`) +- **Fallback**: `RawToolCallParser` parses XML-style `` from raw + text when native tool-use is absent (`SpringAgentLoopActions.java:233–245`) +- **Truncation**: multiple tool calls in one LLM response are **cut to the + first** (`SpringAgentLoopActions.java:210–212`) +- **Error detection**: heuristic prefix match in + `ToolObservationClassifier` (`"HTTP error "`, `"Error: "`, + `"Exception occurred in tool:"`). Tool methods return `String`, not + structured failures. + +### 1.4 Model selection + +- `DelegatingAgentChatModel` picks a model from `SpringAIModelRegistry` per + `think()` (`AgentAutoConfig.java:69–74`) +- **One model for every step** — `SummaryModelInvoker.java:71–75` uses the + same `chatModel` as the main loop. No classifier/router/summariser + specialisation. +- `FixedModelChatAICommand`, `ChatAICommand`, `RawModelAICommand`, + `ModelListAICommand` already exist (`opendaimon-common/.../ai/command/`), + plus gateways `SpringAIGateway`, `ModelListAIGateway`, `MockGateway`. The + infrastructure for multi-model orchestration **is already in place** — + only the orchestrator is missing. + +### 1.5 Memory / history + +- Stored in `ChatMemory` after `answer()` + (`SpringAgentLoopActions.java:823–831`) +- Reloaded on next execution (`:790–812`), trailing USER stripped +- Per-iteration history in `ctx.getExtra(KEY_CONVERSATION_HISTORY)`; grows + monotonically — **no token-based window, no mid-loop compaction** +- `SummarizingChatMemory` may summarise outside the loop (per + `SPRING_AI_MODULE.md` 618), but inside the agent loop the context just + accumulates + +### 1.6 Missing vs. Claude-grade + +| Capability | Present? | Evidence | +|---|---|---| +| Native tool_use (structured API blocks) | No — text parsing | `RawToolCallParser` used as first-class path for some models | +| Parallel tool calls | No — cut to 1 | `SpringAgentLoopActions.java:210–212` | +| Prompt caching (Anthropic `cache_control` etc.) | No | No `cache_control` anywhere in codebase | +| Extended thinking | No — field exists, not wired | `ChatAICommand.maxReasoningTokens` declared; never forwarded to provider | +| Multi-model pipeline (router/worker/summariser) | No | Same model everywhere | +| In-loop token-based compaction | No | History grows without bound | +| Sub-agents | No | No `launch_subagent` tool; no isolated child executor | +| Planning step | No | Loop goes straight to THINKING | + +--- + +## 2. Reference architecture — Claude on OpenRouter + +### 2.1 What Claude natively gives you + +- **Structured tool_use** in the Messages API: `content: [{type: "tool_use", + id, name, input}]`. Model returns tool calls as first-class content + blocks, not regex-extractable text. +- **Parallel tool use**: a single assistant turn can emit N tool_use blocks; + the app executes them in parallel and returns N matching `tool_result` + blocks in the next user turn. +- **Extended thinking**: `thinking: {type: "enabled", budget_tokens: N}` — + model produces a separate `thinking` content block invisible to the user + but visible to the app. +- **Prompt caching**: `cache_control: {type: "ephemeral"}` markers on + `system`, `tools`, or message blocks. 5-minute TTL, ~90% read discount. + Used for system prompt + tools definitions + stable history prefix. +- **One model per conversation**: Anthropic itself does **not** run + router/worker/summariser multi-model pipelines. Specialisation is the + application's job. + +### 2.2 How this maps to OpenRouter + +OpenRouter proxies Claude, so these features are reachable via the chat +completions endpoint, but the concrete field names differ: + +- Tool-use: OpenAI-compatible `tools` + `tool_calls` (OpenRouter normalises + Anthropic's `tool_use` to OpenAI format). **Spring AI + `ToolCallingManager` already speaks this dialect** — the structural path + works today. +- Parallel tool calls: OpenAI `tool_calls` is an array — multiplicity is + natural. Our truncation at + `SpringAgentLoopActions.java:210–212` is a self-inflicted limit. +- Extended thinking: OpenRouter exposes it as + `reasoning: {effort: "low|medium|high", max_tokens: N}` for + reasoning-capable models (Claude, Gemini 2.5, GPT-o, DeepSeek-R1). Must + be sent via `ChatOptions.additionalParameters` in Spring AI. +- Prompt caching: OpenRouter forwards Anthropic's `cache_control` inside + `extra_body` for Anthropic models; for OpenAI models it uses the + `prompt_cache_key` convention; Gemini is automatic. The capability + varies per model — we need a `PROMPT_CACHE` model capability flag in + `SpringAIModelRegistry`. + +### 2.3 Agent loop on top of this + +Claude Code (Anthropic's CLI) runs a loop that is **almost trivially +simple**: + +``` +while True: + response = messages.create( + model=..., system=..., tools=..., messages=history, + thinking=..., extra_body={cache_control...}) + if response has tool_use blocks: + results = parallel_execute(tool_uses) + history.append(assistant_message) + history.append(user_message(tool_results)) + else: + return response.text +``` + +The "smartness" comes from **the model**, **tool quality**, **prompt +caching**, and **application-level scaffolding** (sub-agents, skills, +planning) — not from a baroque loop state machine. This is the single +most important insight for the roadmap: resist complicating the loop; +instead, enrich its inputs. + +--- + +## 3. Roadmap — 8 steps with priorities + +Priority legend: +- **P0** — must-have, largest ROI, minimal breakage, do first +- **P1** — high value, moderate effort +- **P2** — nice-to-have, clear benefit in narrow scenarios +- **P3** — optional / speculative + +| # | Step | Priority | Effort | Risk | Depends on | +|---|---|---|---|---|---| +| **0** | **Minimum unblock: remove advisors + `cache_control` bootstrap** | **P0 (gate)** | **1–3 d** | **Low** | **—** | +| 1 | Prompt caching fine-tuning (metrics, breakpoint placement) | P0 | 1–2 d | Low | 0 | +| 2 | Native tool_use as first-class, regex parser fallback only | P0 | 2–3 d | Low | 0 | +| 3 | Parallel tool calls | P0 | 1 d | Low | 2 | +| 4 | Multi-model pipeline (router / worker / summariser) | P0 | 2–3 d | Med | 0 | +| 5 | Extended thinking (wire `maxReasoningTokens` end-to-end) | P0 | 1 d | Low | 0 | +| 6 | In-loop token-based history compaction | P1 | 3–4 d | Med | 4 | +| 7 | Sub-agents (`launch_subagent` tool) | P2 | 1 w | High | 2, 4 | +| 8 | Explicit planning step in FSM | P3 | 2–3 d | Med | — | + +**Step 0 is a gate for the rest of the P0 work.** Without a stable request +prefix, prompt caching (step 1) cannot demonstrate any benefit regardless +of how carefully we place breakpoints. All five P0 steps either depend on +step 0 directly or benefit strongly from its stability guarantees. Run it +first, ship it, measure `cache_read_input_tokens` ratio, then proceed. + +Steps 1–5 together deliver ~80% of the gap vs. Claude Code. Steps 6–8 are +frontier improvements whose return diminishes if 1–5 are not in place. + +**Recommended execution order** (given the controlled-flow philosophy): + +0. **Sprint 0 — the gate.** Ship step 0 alone, merge, run one week in + production, collect `cache_read_input_tokens / total_input_tokens` + metrics. This is both the minimum unblock for caching and a sanity + check that request prefixes are actually stable. Do not start any + other P0 step until this is green. +1. **Sprint 1 — economics & structure.** Ship steps 1 (caching + fine-tuning / breakpoint optimisation) and 4 (multi-model pipeline) + *in parallel if two people are free*. Step 4 is the architectural + anchor of controlled flow: router + worker + summariser. Step 1 + refines the basic caching from step 0 — placing breakpoints + explicitly on system/tools/history boundaries for maximum cache hit + ratio. Together they reshape the cost/quality curve. +2. **Sprint 2 — correctness.** Step 2 (native tool_use first-class) + + step 3 (parallel tools). These remove latent bugs in text parsing and + the 2–5× latency loss on multi-tool turns. +3. **Sprint 3 — reasoning.** Step 5 (extended thinking) — quick win once + the capability plumbing from step 1 is in place. +4. **Later.** Step 6 when long-context issues actually appear in logs. + Step 7 only if subtasks grow large enough to justify isolated + contexts. Step 8 only if step 5 proves insufficient on complex multi- + step tasks — skip otherwise. + +--- + +## 4. Step details — P0 (0–5) + +### Step 0 — Minimum unblock: message ordering + `cache_control` bootstrap + +**Goal.** Make the outgoing request prefix **deterministic across turns** +of the same conversation, and turn on **automatic prompt caching** via +OpenRouter's top-level `cache_control` flag. This is the prerequisite for +every economic benefit in steps 1–5: without a stable prefix, +`cache_read_input_tokens` stays near zero regardless of how carefully +later steps place breakpoints. + +#### Background — the advisor reorder problem + +`SpringAIPromptFactory.java:105–107` currently attaches two advisors: + +- `MessageChatMemoryAdvisor` — injects history from `ChatMemory` into + the prompt but puts it **before** system messages (known Spring AI + issue #4170). +- `MessageOrderingAdvisor` (project-local, `advisor/` package) — + compensates by regrouping: current-system → history-system → + non-system. + +Two side-effects hostile to caching: + +1. **Group-by-type reordering is not bit-stable** when the count of + system messages varies per turn (dynamic language hint, summary + injection, attachment notices). If turn N has 2 system messages and + turn N+1 has 3, the position of every non-system message shifts → + cache miss on the full history. +2. `SummarizingChatMemory` periodically rewrites older messages into a + rolling summary. When it fires, the middle of the prefix changes, + killing any cache built up above it. This is invisible to the + caller. + +Combined: the prefix is effectively random between cache-friendly +windows. Turning on `cache_control` without fixing this produces ~0% +cache hit ratio. + +#### Scope — 3 substeps, `opendaimon-spring-ai` only + +Intentionally narrow: **no JPA changes, no new services, no business +logic edits**. Just cut the advisor chain, verify callers already pass +full history, and add one line for caching. + +**Step 0.1 — remove advisor chain.** + +- `SpringAIPromptFactory.java:105–107` — delete the three + `.advisors(...)` calls. The chain becomes empty. +- `MessageOrderingAdvisor.java` — mark + `@Deprecated(forRemoval = true)` with a javadoc pointer to this doc. + **Do not delete in the same commit** — keeps the diff minimal and + lets us revert quickly if the fixture suite surfaces a regression. + Actual deletion in a follow-up cleanup commit after one week in + production. + +**Step 0.2 — compensate at call sites.** + +- **Agent path** (`SpringAgentLoopActions.java:790–812`): already reads + history explicitly from `ChatMemory` and assembles + `ctx.getExtra(KEY_CONVERSATION_HISTORY)`. **No change needed.** An + important side effect to verify: the duplication that used to exist + (advisor injecting + agent loading) disappears. Inspect the outgoing + `messages` list size before and after — it should shrink to exactly + what the agent built, with no ghosted history. +- **Non-agent chat path** (through `SpringAIGateway` → + `SpringAIChatService.callChat(messages)`): find every call site that + relied on advisor auto-injection. For each, load + `chatMemoryProvider.getIfAvailable().get(conversationId)` explicitly + and prepend to `messages` before the call. Candidate entry point: + `SpringAIGateway.chat(...)` / `chatStream(...)` — wherever a bare + user message goes into Spring AI without pre-loaded history. + +**Step 0.3 — add `cache_control` bootstrap (automatic mode).** + +New capability plumbing + one `put` in the existing extraBody branch: + +- `ModelCapabilities` enum (`opendaimon-common/.../ai/`): add + `PROMPT_CACHE`. +- `SpringAIModelConfig` / `SpringAIModelRegistry`: populate + `PROMPT_CACHE=true` for Anthropic models on OpenRouter + (`anthropic/claude-*`), **false otherwise**. OpenAI and Gemini handle + caching automatically on the provider side — they do not use our + `cache_control` flag and should not receive it. +- `FeatureToggle.Feature.PROMPT_CACHE` — global kill switch per project + convention (no string literals in `@ConditionalOnProperty`). +- `SpringAIPromptFactory.java:189–215` — in the OpenAI branch that + already sets `extraBody.reasoning`, add: + ```java + if (featureToggle.isEnabled(PROMPT_CACHE) + && modelConfig.hasCapability(PROMPT_CACHE)) { + extraBody.put("cache_control", + Map.of("type", "ephemeral")); + } + ``` + This uses OpenRouter's **automatic mode**: top-level `cache_control` + flag, OpenRouter determines breakpoint position itself by scanning + for the longest stable prefix against prior requests. No + message-content rewriting required — messages stay plain strings. + +#### Tests + +- `SpringAIGatewayMemoryAdvisorTest` — currently asserts advisor-chain + behaviour that ceases to exist. Replace with + `SpringAIHistoryLoadingTest` covering: (a) agent path passes + pre-loaded history to `callChat`, (b) non-agent path loads from + `ChatMemory` at the call site, (c) no double-load. +- `SpringAIPromptFactoryTest` — new cases: + `shouldAddCacheControlWhenModelSupportsCachingAndFeatureEnabled`, + `shouldNotAddCacheControlWhenFeatureDisabled`, + `shouldNotAddCacheControlForNonAnthropicModel`. +- `MessageOrderingAdvisorTest` — leave unchanged until the advisor is + actually deleted; its tests still validate current behaviour of the + deprecated class. + +#### Verification + +1. `./mvnw clean compile -pl opendaimon-spring-ai -am` — compile fence. +2. Targeted unit tests for the modified classes. +3. Fixture smoke: `./mvnw clean verify -pl opendaimon-app -am -Pfixture` + — exercises end-to-end agent and chat flows. Required to pass + before merge. +4. Manual IT via `AgentModeOpenRouterManualIT` against Claude through + OpenRouter: + - On two consecutive iterations within one conversation, log the + outgoing request JSON. Bytes of the request up to the last + user/tool-result message must be **identical**. + - On the response side, inspect + `usage.cache_creation_input_tokens` (grows on turn 1) and + `usage.cache_read_input_tokens` (grows on turns 2+). Target + ratio: `cache_read / total_input_tokens` ≥ 0.5 on turn 2 of a + typical ReAct loop. +5. Update `SPRING_AI_MODULE.md` in the same commit — rewrite sections + on advisor chain, memory ordering, and cache behaviour per + `AGENTS.md` documentation-maintenance rule. + +#### Explicitly deferred — NOT part of Step 0 + +These items are discussed elsewhere in this roadmap and should **not** +be bundled into Step 0 even though they are conceptually related: + +- `ConversationHistoryService` as an application-owned abstraction + over the JPA `Message` entity (makes ordering explicit at the data + layer rather than via Spring AI `ChatMemory`). Future work — + unlocks sharper breakpoint placement for step 1. +- Demoting `SummarizingChatMemory` from transparent wrapper to + explicit callable `HistoryCompactor`. Part of step 6. +- Immutable `Message(type=SUMMARY, replaces_ids=[…], version=N)` + records. Part of step 6. +- Explicit per-block `cache_control` breakpoints (OpenRouter's + manual mode — cache_control inside content arrays). Part of step 1 + fine-tuning. + +Bundling any of these into Step 0 inflates scope, blurs the +measurement (you will not know which change produced which metric +move), and delays the cache-ratio signal that tells us the fix +actually works. + +#### Effort / risk + +**1–3 dev-days.** Low risk: advisor removal is a focused change in a +single factory class; the cache_control addition is one `put` call in +a method that already manipulates `extraBody`. Main risk: undiscovered +call sites relying on advisor auto-injection — mitigated by the +fixture smoke suite, which exercises both agent and non-agent paths +end-to-end. + +--- + +### Step 1 — Prompt caching (fine-tuning beyond automatic mode) + +**Prerequisite.** Step 0 must be merged and showing a non-zero +`cache_read_input_tokens / total_input_tokens` ratio in production. +Without that baseline, this step has nothing to improve. + +**Goal.** Move from OpenRouter's automatic mode (single breakpoint, +placed by the provider) to explicit per-block breakpoints — Режим 2 of +the OpenRouter docs — so that the cache window covers system prompt +and tool definitions independently of the conversation tail. Expected +improvement: cache ratio from ~50–70% (automatic) to ~85–95% +(explicit) on multi-turn conversations. + +**Why.** Automatic mode (Step 0) marks the *last* cacheable block — +anything earlier benefits only if bit-stable. Explicit breakpoints on +the `system` block and at the end of `tools` cache those large, stable +sections independently, so even when the conversation tail shifts +frequently, the big static parts stay hit. On turn N of a 10-step +ReAct loop this is the difference between caching the tool-result tail +only versus caching the entire system + tools + stable-history +prefix — a further 3–5× reduction on top of the Step 0 baseline. + +**Key files to touch.** +- `opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIGateway.java` + — currently builds `Prompt` + `ChatOptions`; add cache_control injection + into the outgoing request via `ChatOptions.additionalParameters` or + provider-specific `extra_body`. Locate by method assembling the + `Prompt` object (`SpringAIGateway.java:363–494`). +- `opendaimon-spring-ai/.../agent/AgentPromptBuilder.java` — the section + boundaries we want to cache (ReAct instructions, tool discipline) are + already static; wrap them in a structural marker understood by + `SpringAIGateway` (e.g. put a sentinel in message metadata). +- `SpringAIModelRegistry` (exact path: see + `opendaimon-spring-ai/.../service/` — look for model registry class) — + add `PROMPT_CACHE` capability, set true only for Anthropic-family + models on OpenRouter (Claude 3+, including Sonnet 4.x, Opus 4.x, + Haiku 4.x). +- `ModelCapabilities` enum (`opendaimon-common/.../ai/`) — add + `PROMPT_CACHE` value. + +**Anthropic cache_control placement (through OpenRouter).** +- System prompt: single `cache_control` marker at the end of the + system block. Everything up to and including the marker is cached. +- Tools: single marker at the end of the `tools` array. +- Messages: marker on the last message you want cached (the cache + extends up through that message). Put it on the most recent + *stable* assistant/tool-result boundary. + +Reuse: the existing `FeatureToggle.Feature` pattern for capability +gating; the existing `DelegatingAgentChatModel` for picking the +caching-capable provider. + +**Verification.** +- Unit test: build request, assert `cache_control` present on + expected blocks for a cacheable model, absent for a + non-cacheable one. +- Manual IT: run `AgentModeOpenRouterManualIT` against Claude via + OpenRouter with debug logging; confirm the response's + `usage.prompt_tokens_details.cached_tokens` (or equivalent on + OpenRouter) grows across turns. + +### Step 2 — Native tool_use first-class + +**Goal.** Remove reliance on `RawToolCallParser` (XML-in-text) for +models that support structured tool calling; keep it only as a +fallback for local models that cannot. + +**Why.** Today `SpringAgentLoopActions.think()` (lines 207–245) checks +for structured tool calls first, then falls back to regex. For +Claude/GPT/Gemini on OpenRouter the regex path should be unreachable — +but it is currently a live code path that we implicitly rely on when +model output is malformed. Having it as the fallback masks bugs and +allows broken prompts to limp along. Promote native to the only path +for `TOOL_USE`-capable models; if the model output has no structured +tool call and no final text, treat that as an error worth surfacing +(not as an invitation to regex-parse assistant prose). + +**Key files.** +- `SpringAgentLoopActions.java:207–245` — gate the fallback on a new + capability check (`ModelCapabilities.NATIVE_TOOL_USE`); emit a + structured error for capable models that still return malformed + output. +- `RawToolCallParser` — keep, but move to a package clearly labelled + "legacy/local-models". +- `SpringAIModelRegistry` — add `NATIVE_TOOL_USE` capability + (Claude/GPT/Gemini/Mistral-Large on OpenRouter = true; local + Ollama models = usually false). +- `AgentPromptBuilder.java:43–48` — the "always-appended" tool + discipline section becomes conditional: for native-tool-use models + the schema lives in `tools`, not in the system prompt (reducing + system tokens). + +**Verification.** +- Existing `AgentPromptBuilderTest` + new test: with + `NATIVE_TOOL_USE=true` the tool discipline section is not + injected into system. +- Fixture smoke tests (`./mvnw clean verify -pl opendaimon-app -am + -Pfixture`) — they exercise end-to-end agent flows. + +### Step 3 — Parallel tool calls + +**Goal.** Honour multiple `tool_calls` in a single assistant turn by +executing them concurrently and returning one batched +`user(tool_result)` message. + +**Why.** `SpringAgentLoopActions.java:210–212` explicitly picks the +first tool call and drops the rest. For latency-bound workloads +(e.g. two independent HTTP fetches) this is a 2–5× slowdown. + +**Key files.** +- `SpringAgentLoopActions.java:210–212` — remove truncation; iterate + all tool calls. +- `SpringAgentLoopActions.executeTool()` (:395–440) — split into + `executeToolBatch(List)`. Use a bounded executor + (`ExecutorService`, size derived from + `agentToolCallbacks.size()` or from a config). Respect + cancellation (`ctx.isCancelled()`). +- `AgentStepResult` — widen to carry `List`. +- `ToolObservationClassifier` — operate per-tool, aggregate. +- Tool implementations (`WebTools`, `HttpApiTool`) — audit for + thread-safety; most are already stateless HTTP wrappers, safe by + construction. + +**Risk.** Two concurrent tool calls that both mutate `ctx` would +race. Today `ctx` mutation happens in the *action* code, not inside +tools — tools return `String`. Keep it that way; do not let tools +mutate `ctx` directly. + +**Verification.** New unit test in `SpringAgentLoopActionsTest` with +a synthetic LLM response containing 3 tool calls; assert all 3 +observations are recorded and order is preserved in +`AgentStepResult`. + +### Step 4 — Multi-model pipeline (router / worker / summariser) + +**Goal.** Introduce a small orchestration layer so that different +stages of the request use appropriately-sized models. Exploit +existing `AiCommand`/`AiGateway` machinery. + +**Stages.** + +1. **Router** (fast, cheap — e.g. Claude Haiku 4.5 / GPT-4.1-mini + via OpenRouter). Input: raw user text + recent history summary. + Output: structured JSON with fields + `{ intent, needed_capabilities[], recommended_model_tier, + requires_vision, requires_tools }`. Runs once per request at + the start of `AIRequestPipeline`. +2. **Worker** (the current `DelegatingAgentChatModel` path — Sonnet + / Opus). Runs the ReAct loop. Model tier picked from router + output + user priority. +3. **Summariser** (Haiku-tier). Replaces the current + `SummaryModelInvoker` same-model call. Used for: + (a) MAX_ITERATIONS fallback summary, + (b) in-loop compaction (step 6). + +**Key files.** +- `opendaimon-common/.../ai/pipeline/AIRequestPipeline.java` — + insert `RouterStage` before `AICommandFactoryRegistry`. +- New class `RouterAiCommand extends ChatAICommand` with + `modelCapabilities = {FAST_CLASSIFIER}`, small + `maxTokens`, structured-output hint in systemRole. +- `DefaultAICommandFactory.java:77–174` — accept router output as + an input; let it override `requiredCapabilities` / + `optionalCapabilities` per request. +- `SummaryModelInvoker.java:40–75` — inject a dedicated + `summaryChatModel` bean chosen by capability + `{FAST_SUMMARISER}`, not the primary chat model. +- `SpringAIModelRegistry` — add `FAST_CLASSIFIER` and + `FAST_SUMMARISER` capabilities; map to Haiku-tier OpenRouter + models. + +**Priority integration.** Keep `PriorityRequestExecutor` at the +outer boundary (one slot per user per request); router/summariser +calls happen *inside* that slot and do not consume additional +per-user permits. + +**Verification.** +- Unit test for `RouterAiCommand` JSON output parsing. +- IT with two user messages — one trivial ("hi"), one complex + ("compare these 3 PDFs"); confirm router routes them to + different capability sets. + +### Step 5 — Extended thinking + +**Goal.** Forward `ChatAICommand.maxReasoningTokens` to the +provider so that reasoning-capable models (Claude 3.7+ / Gemini +2.5 / GPT-o / DeepSeek-R1 on OpenRouter) produce an internal +thinking block before the final answer. + +**Why.** The field exists in `ChatAICommand` today but is never +read downstream. This is low-effort, immediate quality gain on +reasoning-heavy tasks — for free on supported models. + +**Key files.** +- `SpringAIGateway.java` — where `ChatOptions` is built for the + outgoing request (method building the `Prompt`; look around + `:363–494`). Emit OpenRouter-style + `reasoning: {max_tokens: N}` or the model's native equivalent. +- Add `ModelCapabilities.EXTENDED_THINKING`; populate in + `SpringAIModelRegistry` for supporting models. +- `DefaultAICommandFactory.java` — set sensible default for + `maxReasoningTokens` per user priority tier (ADMIN=8000, + VIP=4000, REGULAR=2000 as a starting point). +- `SpringAgentLoopActions.think()` — when the response carries a + thinking block (separate from text / tool_use), record it into + `AgentStepResult.reasoning` rather than swallowing it. The field + exists (see `AgentTextSanitizer.extractReasoning` at + `SpringAgentLoopActions.java:196–201`); rewire it to read from + the structural block, not from in-text `` regex. + +**Verification.** +- Unit test: request built for an `EXTENDED_THINKING` model + contains the `reasoning` field with expected budget. +- Manual IT: enable thinking, run a multi-step task, inspect + response for non-empty thinking block. + +--- + +## 5. Step details — P1–P3 (6–8) + +### Step 6 — In-loop token-based history compaction (P1) + +**Goal.** Keep the working context from growing unboundedly during +long multi-step loops. + +**Shape.** +- Add a token counter (reuse Spring AI `Tokenizer` bean) to count + `ctx.getExtra(KEY_CONVERSATION_HISTORY)` after each observation. +- When the total exceeds a soft threshold (e.g. 60% of model + context), invoke the summariser model (from step 4) on the + **middle** of the history, preserving the first system + last + K turns verbatim. Replace the compacted slice with a + `SystemMessage("Earlier in this conversation:

")`. +- Do not touch the current turn's messages. Do not compact across + a partially-completed tool call. + +**Key files.** +- `SpringAgentLoopActions.java:790–812` (history assembly) — + invoke compactor after appending observation. +- New `HistoryCompactor` service in the `agent` package. +- `SummaryModelInvoker` — extended with `compact(List, + preserveHead, preserveTail)`. + +**Depends on step 4** — without a dedicated cheap summariser this +becomes prohibitively expensive. + +### Step 7 — Sub-agents (P2) + +**Goal.** Let the agent delegate a self-contained sub-task +(long research, codebase scan) to a child agent with an isolated +context window. + +**Shape.** +- New `@Tool` method `launchSubagent(task: String, tools: String[])` + that calls back into `ReActAgentExecutor` with a fresh + `AgentContext`. Result: a single string the parent incorporates. +- Child agent inherits nothing from parent history except the + explicit `task`. Child runs its own loop, returns a + summary. Parent sees `tool_result = `. +- Guard against unbounded recursion: max depth 2, configurable. + +**Why P2, not higher.** Sub-agents multiply model spend; they are +only a win when the subtask is large enough to benefit from an +isolated context. In open-daimon the current workloads rarely +qualify. Revisit after step 6 when long contexts become common. + +### Step 8 — Explicit planning step (P3) + +**Goal.** Before the first THINKING iteration, produce a structured +plan of N sub-steps. + +**Shape.** New `PLANNING` FSM state between the initial task and +the first `THINKING`; emits a JSON plan; THINKING iterations +receive `plan[i]` as focus. + +**Why P3.** Claude Code deliberately does not do this. Its +observation is that a sufficiently good model plans implicitly and +revises on the fly, while an explicit planner adds latency and +becomes brittle when reality deviates. Ship only if step 5 +(extended thinking) is insufficient for complex multi-step tasks +in real traffic. + +--- + +## 6. Critical files — reference + +Implementation work will concentrate in: + +- `opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java` +- `opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilder.java` +- `opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SummaryModelInvoker.java` +- `opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIGateway.java` +- `opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java` +- `opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/command/ChatAICommand.java` +- `opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/factory/DefaultAICommandFactory.java` +- `opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/pipeline/AIRequestPipeline.java` +- `opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/ModelCapabilities` (enum) +- `opendaimon-spring-ai/.../service/SpringAIModelRegistry` (capability map) +- `opendaimon-spring-ai/SPRING_AI_MODULE.md` — **must be updated in the + same commit** as each step per project rule (AGENTS.md + "Documentation maintenance"). Relevant sections: loop description, + prompt composition, tool-call handling, model selection, memory + management. + +Reuse (do not re-invent): +- `PriorityRequestExecutor` — stays at the outer boundary, one slot + per user per end-to-end request, regardless of internal multi-model + fan-out. +- `FeatureToggle` — every new capability gets a toggle, not a raw + string literal. +- `ChatMemory` / `SummarizingChatMemory` — continues to handle + inter-conversation memory; in-loop compaction (step 6) is + complementary, not a replacement. +- Existing tests: `AgentPromptBuilderTest` — extend rather than + duplicate. + +--- + +## 7. Verification plan + +Per step (see each step for specifics), but the end-to-end smoke +is uniform: + +1. `./mvnw clean compile` — compile fence. +2. Targeted unit tests for the modified class(es). +3. Fixture smoke: `./mvnw clean verify -pl opendaimon-app -am + -Pfixture` when the step touches agent flow. +4. Manual IT via `AgentModeOpenRouterManualIT` — run the full ReAct + loop against OpenRouter / Claude with DEBUG logging; inspect + request payload for the new field (cache_control / reasoning / + tools array / cached_tokens counter), inspect response for the + expected structural block. +5. Update `SPRING_AI_MODULE.md` describing the behaviour change in + the same commit. + +--- + +## 8. Do we even need the custom loop? (Recorded decision) + +A fair question was raised: could we have skipped the ReAct loop +entirely and relied on Spring AI's built-in tool-calling path +(`ChatClient.prompt().tools(...).call()` via `ToolCallingManager`)? + +**Answer: the loop *as an orchestration layer* is necessary; the +*ReAct text protocol* on top of it is not.** These are two separable +things that are currently fused. + +What the Spring AI native path would *not* have given us, and which +`ReActAgentExecutor` + FSM provides today: + +- Streaming intermediate `AgentStreamEvent`s (thought, tool_call, + observation) to Telegram / UI — `ChatClient.call()` yields only + the final response. +- Mid-iteration cancellation via `ctx.isCancelled()` + (`SpringAgentLoopActions.java:140–144`). +- MAX_ITERATIONS with a fallback summary (`SummaryModelInvoker`) + — the native path either loops unboundedly or hard-times-out. +- `GuardedFetchUrlCallback` to prevent retrying the same failed URL + (`:585–690`). +- Per-step metrics, `AgentStepResult`, and error classification via + `ToolObservationClassifier`. +- Integration with `PriorityRequestExecutor` at the per-iteration + granularity rather than only at the outer request boundary. + +What *was* redundant once OpenRouter/Claude gave us native tool-use: + +- The `` / `Thought:/Action:/Observation:` prose protocol in + `AgentPromptBuilder.java:24–48`. +- `RawToolCallParser` as a first-class code path. +- `AgentTextSanitizer.extractReasoning` regex on assistant text — a + workaround for the missing thinking block, obsoleted by step 5. +- The parallel-tool-call truncation at + `SpringAgentLoopActions.java:210–212` — a self-imposed limit. + +**Decision for the roadmap.** Keep the FSM — it is the orchestration +layer and has no cheap equivalent in Spring AI core. Dismantle the +ReAct *text protocol* incrementally through step 2 (promote native +tool-use to the only path for `NATIVE_TOOL_USE`-capable models; +demote regex parsing to a fallback for local Ollama-style models). +Do **not** attempt a ground-up rewrite around `ChatClient.call()`: +the value we would lose (streaming events, cancellation, +MAX_ITERATIONS summary, priority integration) outweighs the +simplification. + +If at some future point we drop all non-tool-use models, the FSM +could shrink to ~3 states (THINKING / TOOL_BATCH / ANSWERING) and +much of `SpringAgentLoopActions` could collapse into simpler +handlers. But that is a refactor to be driven by evidence, not by +aesthetics, and it is out of scope for this roadmap. + +--- + +## 9. What we are deliberately **not** doing + +- **Regex-based input routing.** The user asked whether to branch + prompts based on regex on user input. No. That pattern is + brittle and accretes into an unmaintainable rule tree. The + Router stage in step 4 uses an LLM classifier with structured + output, which is both smarter and easier to evolve. +- **Hard-coded per-command prompts.** System prompts stay assembled + from components (identity + tools + language + memory), not + duplicated per `AiCommand` subclass. Dynamic variation flows + through capability flags and metadata — not through prompt + forking. +- **Replacing the FSM with a bare while-loop.** The FSM adds real + value for cancellation, observability, and MAX_ITERATIONS + handling. Claude Code uses a simple while-loop because it has no + equivalent of our streaming/cancellation/priority-queue + surrounding infrastructure. Keep the FSM; enrich its inputs. diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java index 43975329..8a5f28f2 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java @@ -460,9 +460,45 @@ void regular_agentSimple_noTools() { .isFalse(); } + // --- B9: Language-aware system prompt — agent responds in Russian --- + + @Test + @Timeout(3 * 60) + @DisplayName("B9: ADMIN agent responds in Russian when languageCode=ru, including intermediate thoughts") + void admin_agentReact_respondsInRussian_whenLanguageCodeIsRu() { + TelegramCommand command = createMessageCommand( + ADMIN_CHAT_ID, + 11, + "Что такое Spring Boot? Поищи в интернете и ответь кратко.", + "ru" + ); + + messageHandler.handle(command); + + TelegramUser user = telegramUserRepository.findByTelegramId(ADMIN_CHAT_ID) + .orElseThrow(() -> new IllegalStateException("Telegram user should be created")); + + ConversationThread thread = threadRepository.findMostRecentActiveThread(user) + .orElseThrow(() -> new IllegalStateException("Active thread should exist")); + + String assistantReply = latestAssistantReply(thread); + + assertThat(assistantReply) + .as("Agent should produce a non-blank response") + .isNotBlank(); + + assertThat(assistantReply) + .as("Agent response must contain Cyrillic characters — language-aware prompt should make LLM reply in Russian") + .matches("(?s).*[\\p{IsCyrillic}]+.*"); + } + // --- Helpers --- private TelegramCommand createMessageCommand(Long chatId, int messageId, String text) { + return createMessageCommand(chatId, messageId, text, "en"); + } + + private TelegramCommand createMessageCommand(Long chatId, int messageId, String text, String languageCode) { Update update = new Update(); User from = new User(); @@ -470,7 +506,7 @@ private TelegramCommand createMessageCommand(Long chatId, int messageId, String from.setUserName("manual-agent-user-" + chatId); from.setFirstName("Manual"); from.setLastName("Agent"); - from.setLanguageCode("en"); + from.setLanguageCode(languageCode); Message message = new Message(); message.setMessageId(messageId); @@ -490,7 +526,7 @@ private TelegramCommand createMessageCommand(Long chatId, int messageId, String false, List.of() ); - command.languageCode("en"); + command.languageCode(languageCode); return command; } diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/lang/LanguageInstructions.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/lang/LanguageInstructions.java new file mode 100644 index 00000000..a665aa1a --- /dev/null +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/lang/LanguageInstructions.java @@ -0,0 +1,34 @@ +package io.github.ngirchev.opendaimon.common.ai.lang; + +import java.util.Locale; +import java.util.Optional; + +/** + * Utility for resolving human-readable language names from ISO 639 / BCP 47 codes. + * Uses {@link Locale#getDisplayLanguage(Locale)} so the full JDK language table is supported, + * not a hardcoded subset. + */ +public final class LanguageInstructions { + + private LanguageInstructions() { + } + + /** + * Resolves an English display name for the given language code. + * + * @param languageCode ISO 639 / BCP 47 code (e.g. "ru", "zh-Hans", "pt-BR") + * @return display name in English (e.g. "Russian"), or the original code if the JDK + * cannot resolve it, or {@link Optional#empty()} if the input is null or blank + */ + public static Optional displayName(String languageCode) { + if (languageCode == null || languageCode.isBlank()) { + return Optional.empty(); + } + Locale locale = Locale.forLanguageTag(languageCode); + String name = locale.getDisplayLanguage(Locale.ENGLISH); + if (name == null || name.isBlank()) { + return Optional.of(languageCode); + } + return Optional.of(name); + } +} diff --git a/opendaimon-common/src/test/java/io/github/ngirchev/opendaimon/common/ai/lang/LanguageInstructionsTest.java b/opendaimon-common/src/test/java/io/github/ngirchev/opendaimon/common/ai/lang/LanguageInstructionsTest.java new file mode 100644 index 00000000..5b2fc266 --- /dev/null +++ b/opendaimon-common/src/test/java/io/github/ngirchev/opendaimon/common/ai/lang/LanguageInstructionsTest.java @@ -0,0 +1,64 @@ +package io.github.ngirchev.opendaimon.common.ai.lang; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LanguageInstructionsTest { + + @Test + void shouldReturnEnglishNameWhenCodeIsRu() { + Optional result = LanguageInstructions.displayName("ru"); + + assertTrue(result.isPresent()); + assertEquals("Russian", result.get()); + } + + @Test + void shouldReturnEnglishNameWhenCodeIsBcp47WithRegion() { + Optional zhHans = LanguageInstructions.displayName("zh-Hans"); + Optional ptBr = LanguageInstructions.displayName("pt-BR"); + + assertTrue(zhHans.isPresent()); + assertEquals("Chinese", zhHans.get()); + + assertTrue(ptBr.isPresent()); + assertEquals("Portuguese", ptBr.get()); + } + + @Test + void shouldReturnEnglishNameForLessCommonLanguages() { + Optional uk = LanguageInstructions.displayName("uk"); + Optional ja = LanguageInstructions.displayName("ja"); + + assertTrue(uk.isPresent()); + assertEquals("Ukrainian", uk.get()); + + assertTrue(ja.isPresent()); + assertEquals("Japanese", ja.get()); + } + + @Test + void shouldReturnEmptyWhenCodeIsNull() { + assertTrue(LanguageInstructions.displayName(null).isEmpty()); + } + + @Test + void shouldReturnEmptyWhenCodeIsBlank() { + assertTrue(LanguageInstructions.displayName("").isEmpty()); + assertTrue(LanguageInstructions.displayName(" ").isEmpty()); + } + + @Test + void shouldFallbackToCodeWhenUnresolvable() { + // JDK always resolves forLanguageTag to at least a Locale with the language subtag as display name. + // For a private-use tag like "xxx", getDisplayLanguage returns "xxx" — the code itself. + Optional result = LanguageInstructions.displayName("xxx"); + + assertTrue(result.isPresent()); + assertEquals("xxx", result.get()); + } +} diff --git a/opendaimon-spring-ai/SPRING_AI_MODULE.md b/opendaimon-spring-ai/SPRING_AI_MODULE.md index d3b340c8..39b1a3a9 100644 --- a/opendaimon-spring-ai/SPRING_AI_MODULE.md +++ b/opendaimon-spring-ai/SPRING_AI_MODULE.md @@ -317,6 +317,17 @@ Telegram-specific bot identity is already part of `role` metadata from Telegram ## REACT Agent Loop — Iteration Handling The REACT loop lives in `SpringAgentLoopActions` (FSM actions) and is driven by `ReActAgentExecutor`. + +The system prompt is assembled via `AgentPromptBuilder.buildSystemPrompt(metadata)` and enriched with +two additional instructions derived from agent metadata: +- **Tool-calling discipline** — always appended unconditionally, because the agent always operates with + `web_search`/`fetch_url` tools available. Prevents empty-argument tool calls observed on some models. +- **Language instruction** — appended when `LANGUAGE_CODE_FIELD` is present in metadata (e.g. Telegram + passes `languageCode = "ru"`). The instruction covers intermediate thoughts and status messages as well + as the final answer (`"Respond in Russian (ru), INCLUDING intermediate thoughts and status messages"`), + eliminating the bifurcated-language issue where thought tokens appeared in English while the final + answer was in Russian. Language name resolution is handled by `LanguageInstructions.displayName()` in + `opendaimon-common` (JDK `Locale.getDisplayLanguage`, ~180 ISO 639 / BCP 47 codes — no hardcoded switch). Spring AI's built-in tool-execution loop is disabled via `ToolCallingChatOptions.internalToolExecutionEnabled = false`; we drive tool invocations ourselves so that each `THINKING → TOOL_CALL → OBSERVATION` step can be streamed as diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilder.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilder.java index 0d079895..2ece94f9 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilder.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilder.java @@ -2,8 +2,11 @@ import io.github.ngirchev.opendaimon.common.agent.AgentContext; import io.github.ngirchev.opendaimon.common.agent.AgentStepResult; +import io.github.ngirchev.opendaimon.common.ai.command.AICommand; +import io.github.ngirchev.opendaimon.common.ai.lang.LanguageInstructions; import java.util.List; +import java.util.Map; /** * Builds system and user prompts for the ReAct agent loop. @@ -37,11 +40,38 @@ private AgentPromptBuilder() { - If a tool returns an error, try an alternative approach """; + private static final String TOOL_CALLING_INSTRUCTION = + "\nWhen calling any tool, you MUST provide all required parameters" + + " with concrete non-empty values. Never emit a tool call with empty" + + " or null arguments. For web_search, always include a non-empty" + + " `query` string describing what to search. For fetch_url, always" + + " include a valid http(s) `url`."; + /** - * Builds the system prompt including ReAct instructions. + * Builds the system prompt enriched with language and tool-calling instructions + * derived from agent metadata. + * + *

The tool-calling discipline instruction is appended unconditionally because + * the ReAct agent always operates with web_search/fetch_url tools available. + * The language instruction is appended only when {@link AICommand#LANGUAGE_CODE_FIELD} + * is present in the metadata — it covers intermediate thoughts and status messages + * as well as the final answer to eliminate bifurcated-language output. + * + * @param metadata agent metadata from {@link AgentContext#getMetadata()}, may be {@code null} */ - public static String buildSystemPrompt() { - return REACT_SYSTEM_PROMPT; + public static String buildSystemPrompt(Map metadata) { + String prompt = REACT_SYSTEM_PROMPT + TOOL_CALLING_INSTRUCTION; + return appendLanguageInstruction(prompt, metadata); + } + + private static String appendLanguageInstruction(String prompt, Map metadata) { + if (metadata == null) return prompt; + String code = metadata.get(AICommand.LANGUAGE_CODE_FIELD); + return LanguageInstructions.displayName(code) + .map(name -> prompt + + "\nRespond in " + name + " (" + code + "), INCLUDING intermediate thoughts and status messages." + + " When quoting text from documents or tool results, preserve the original language exactly.") + .orElse(prompt); } /** diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java index 36e6ca53..f39d6a48 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java @@ -147,7 +147,7 @@ public void think(AgentContext ctx) { List messages = getOrCreateHistory(ctx); if (messages.isEmpty()) { - String systemPrompt = AgentPromptBuilder.buildSystemPrompt(); + String systemPrompt = AgentPromptBuilder.buildSystemPrompt(ctx.getMetadata()); messages.add(new SystemMessage(systemPrompt)); loadConversationHistory(ctx, messages); messages.add(new UserMessage(AgentPromptBuilder.buildUserMessage(ctx))); diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SummaryModelInvoker.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SummaryModelInvoker.java index 49028680..7877b153 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SummaryModelInvoker.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SummaryModelInvoker.java @@ -2,6 +2,7 @@ import io.github.ngirchev.opendaimon.bulkhead.service.PriorityRequestExecutor; import io.github.ngirchev.opendaimon.common.ai.command.AICommand; +import io.github.ngirchev.opendaimon.common.ai.lang.LanguageInstructions; import io.github.ngirchev.opendaimon.common.agent.AgentContext; import io.github.ngirchev.opendaimon.common.agent.AgentStepResult; import lombok.extern.slf4j.Slf4j; @@ -148,16 +149,8 @@ static Long resolveUserId(Map metadata) { private static String resolveLanguageInstruction(Map metadata) { if (metadata == null) return ""; String code = metadata.get(AICommand.LANGUAGE_CODE_FIELD); - if (code == null || code.isBlank()) return ""; - String name = switch (code.toLowerCase()) { - case "ru" -> "Russian"; - case "en" -> "English"; - case "de" -> "German"; - case "fr" -> "French"; - case "es" -> "Spanish"; - case "zh" -> "Chinese"; - default -> code; - }; - return "Respond in " + name + " (" + code + ")."; + return LanguageInstructions.displayName(code) + .map(name -> "Respond in " + name + " (" + code + ").") + .orElse(""); } } diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIGateway.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIGateway.java index e1c455a6..c93bf939 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIGateway.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIGateway.java @@ -23,6 +23,7 @@ import io.github.ngirchev.opendaimon.common.ai.ModelCapabilities; import io.github.ngirchev.opendaimon.common.ai.command.OpenDaimonChatOptions; import io.github.ngirchev.opendaimon.common.ai.command.AICommand; +import io.github.ngirchev.opendaimon.common.ai.lang.LanguageInstructions; import io.github.ngirchev.opendaimon.common.ai.command.ChatAICommand; import io.github.ngirchev.opendaimon.common.ai.command.FixedModelChatAICommand; import io.github.ngirchev.opendaimon.common.ai.response.AIResponse; @@ -457,19 +458,11 @@ private String appendLanguageInstruction(String systemRole, AICommand command) { return systemRole; } String languageCode = command.metadata().get(AICommand.LANGUAGE_CODE_FIELD); - if (languageCode == null || languageCode.isBlank()) { - return systemRole; - } - String languageName = switch (languageCode.toLowerCase()) { - case "ru" -> "Russian"; - case "en" -> "English"; - case "de" -> "German"; - case "fr" -> "French"; - case "es" -> "Spanish"; - case "zh" -> "Chinese"; - default -> languageCode; - }; - return systemRole + "\nPrefer responding in " + languageName + " (" + languageCode + "). When quoting text from documents or context, preserve the original language exactly."; + return LanguageInstructions.displayName(languageCode) + .map(name -> systemRole + + "\nPrefer responding in " + name + " (" + languageCode + ")." + + " When quoting text from documents or context, preserve the original language exactly.") + .orElse(systemRole); } /** diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilderTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilderTest.java new file mode 100644 index 00000000..32f45f9d --- /dev/null +++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilderTest.java @@ -0,0 +1,39 @@ +package io.github.ngirchev.opendaimon.ai.springai.agent; + +import io.github.ngirchev.opendaimon.common.ai.command.AICommand; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class AgentPromptBuilderTest { + + @Test + void shouldAppendLanguageInstructionWhenMetadataHasLanguageCode() { + Map metadata = Map.of(AICommand.LANGUAGE_CODE_FIELD, "ru"); + + String result = AgentPromptBuilder.buildSystemPrompt(metadata); + + assertThat(result) + .contains("Respond in Russian (ru)") + .contains("INCLUDING intermediate thoughts"); + } + + @Test + void shouldReturnBaseSystemPromptWithoutLanguageWhenMetadataIsNull() { + String result = AgentPromptBuilder.buildSystemPrompt(null); + + assertThat(result) + .contains("You are an AI agent that solves tasks step by step") + .contains("you MUST provide all required parameters") + .doesNotContain("Respond in"); + } + + @Test + void shouldAppendToolCallingInstructionAlways() { + String result = AgentPromptBuilder.buildSystemPrompt(Map.of()); + + assertThat(result).contains("you MUST provide all required parameters"); + } +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java index 67cba052..a17a9001 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java @@ -489,6 +489,8 @@ protected TelegramCommand mapToTelegramCommand(Update update) { telegramCommandType = new TelegramCommandType(TelegramCommand.BUGREPORT); } else if (callbackData.startsWith("MODEL_")) { telegramCommandType = new TelegramCommandType(TelegramCommand.MODEL); + } else if (callbackData.startsWith("MODE_")) { + telegramCommandType = new TelegramCommandType(TelegramCommand.MODE); } } if (telegramCommandType == null) { diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandler.java index 8b201af3..5abfafa8 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandler.java @@ -118,6 +118,20 @@ private void handleCallbackQuery(TelegramCommand command) { String updatedMsg = messageLocalizationService.getMessage("telegram.language.updated", normalized, label); ackCallback(cq.getId(), updatedMsg); deleteMenuMessage(command.telegramId(), cq); + sendConfirmationMessage(command.telegramId(), updatedMsg); + } + + /** + * Posts a persistent confirmation message into the chat so the user sees the + * selected language in conversation history (not just as a transient toast). + */ + private void sendConfirmationMessage(Long chatId, String text) { + try { + SendMessage msg = new SendMessage(chatId.toString(), text); + telegramBotProvider.getObject().execute(msg); + } catch (Exception e) { + log.warn("Failed to send language confirmation message: {}", e.getMessage()); + } } private void sendLanguageMenu(Long chatId, String languageCode, String currentMsg) { From 87c71f02564def1bf549f8ca14d0ae879875c09a Mon Sep 17 00:00:00 2001 From: ngirchev Date: Wed, 22 Apr 2026 00:26:52 +0300 Subject: [PATCH 05/14] Added mode thinking --- .claude/agents/senior-enterprise-java.md | 26 +- CLAUDE.md | 5 +- docs/agent-evolution-roadmap.md | 11 + docs/chat-streaming-disable-toggle.md | 274 ++++++++++++++ docs/feature-toggles.md | 7 + docs/telegram-thinking-modes.md | 167 ++++++++ .../GatewayPassthroughOpenRouterManualIT.java | 2 +- .../common/config/FeatureToggle.java | 4 +- .../opendaimon/common/model/ThinkingMode.java | 18 + .../opendaimon/common/model/User.java | 11 + ..._Add_thinking_preserve_enabled_to_user.sql | 2 + ...e_thinking_preserve_with_thinking_mode.sql | 16 + opendaimon-spring-ai/SPRING_AI_MODULE.md | 31 +- .../opendaimon/ai/springai/tool/WebTools.java | 11 +- .../ai/springai/tool/WebToolsTest.java | 35 +- opendaimon-telegram/TELEGRAM_MODULE.md | 50 ++- .../telegram/command/TelegramCommand.java | 1 + .../impl/ThinkingTelegramCommandHandler.java | 206 ++++++++++ .../fsm/TelegramMessageHandlerActions.java | 40 +- .../config/TelegramCommandHandlerConfig.java | 13 + .../service/TelegramAgentStreamRenderer.java | 6 + .../telegram/service/TelegramUserService.java | 18 + .../resources/messages/telegram_en.properties | 12 + .../resources/messages/telegram_ru.properties | 12 + .../ThinkingTelegramCommandHandlerTest.java | 355 ++++++++++++++++++ ...ramMessageHandlerActionsStreamingTest.java | 88 +++++ 26 files changed, 1373 insertions(+), 48 deletions(-) create mode 100644 docs/chat-streaming-disable-toggle.md create mode 100644 docs/telegram-thinking-modes.md create mode 100644 opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/ThinkingMode.java create mode 100644 opendaimon-common/src/main/resources/db/migration/core/V13__Add_thinking_preserve_enabled_to_user.sql create mode 100644 opendaimon-common/src/main/resources/db/migration/core/V14__Replace_thinking_preserve_with_thinking_mode.sql create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandler.java create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandlerTest.java diff --git a/.claude/agents/senior-enterprise-java.md b/.claude/agents/senior-enterprise-java.md index 31d59eda..8b726d38 100644 --- a/.claude/agents/senior-enterprise-java.md +++ b/.claude/agents/senior-enterprise-java.md @@ -1,6 +1,6 @@ --- name: senior-enterprise-java -description: "Senior Java engineer for multi-module Spring Boot work — analyzing services, writing tests, debugging module behavior in opendaimon-* modules. Use proactively for non-trivial Java coding tasks (>1 file, requires a test, or touches JPA/Spring config). Do NOT use for pure docs/config edits or questions answerable without touching code." +description: "Senior Java engineer for multi-module Spring Boot changes that span >=3 Java files, introduce a new service/entity/migration, or require new unit+integration test coverage in opendaimon-* modules. Orchestrator may handle simpler edits directly. Do NOT invoke for: single-file edits with <50 changed lines; bug fixes with user-supplied logs where the root-cause skill fits; docs-only or config-only changes; continuation of work the orchestrator has already started." model: opus color: blue --- @@ -13,19 +13,17 @@ You are a senior Java engineer on `open-daimon` — a multi-module Java 21 / Spr 2. If Serena reports `Active Project: None`, call `activate_project("open-daimon")` before any symbolic lookup. 3. Open the target module's `*_MODULE.md` (e.g. `opendaimon-spring-ai/SPRING_AI_MODULE.md`) and the matching `docs/usecases/*.md` if the change touches a documented use case. -## Non-negotiable project conventions - -- **Beans:** explicit `@Bean` methods in `config/` classes — never `@Service` / `@Component` / `@Repository` auto-scan. Use `ObjectProvider` for optional beans, `@Lazy` to break cycles at creation. -- **Config:** `@ConfigurationProperties` + `@Validated`; all values required in `application.yml` (no defaults in code). Wrapper types (`Integer`, `Boolean`, `Double`). Namespace `open-daimon.*`; toggles `*.enabled`. -- **Feature toggles:** `FeatureToggle.Module` / `.Feature` / `.TelegramCommand` constants — never raw strings in `@ConditionalOnProperty`. -- **AI calls:** always via `PriorityRequestExecutor` (never call AI services directly). Priorities: ADMIN / VIP / REGULAR. -- **Metrics:** via `OpenDaimonMeterRegistry`, format `..`. -- **Entities:** base (`User`, `Message`) live in `opendaimon-common`. JPA inheritance — JOINED for `User` (discriminator `user_type`), SINGLE_TABLE for `Message` (discriminator `message_type`, metadata JSONB). `@PrePersist` / `@PreUpdate` for timestamps. -- **Packages:** `io.github.ngirchev.opendaimon..`. -- **Services:** `Foo` interface + `FooImpl`, `@RequiredArgsConstructor`, `@Slf4j`. Lombok and Vavr are preferred. -- **Language:** code, comments, javadoc, log and exception messages — English only. User-facing strings may be i18n. -- **Migrations:** `opendaimon-app/src/main/resources/db/migration//V__.sql`, `IF NOT EXISTS`, `TIMESTAMP WITH TIME ZONE`, index FKs. -- **pom.xml:** dependency order = project modules → Spring → DB → utilities → test. All versions in ``. Never add a dependency without approval. +## Style & conventions — loaded by path, do not re-duplicate here + +Full rules live in these files, already in context by the time you run: + +- `AGENTS.md § Project Style Guide` — beans, services, entities, migrations, metrics, pom order. +- `.claude/rules/java/coding-style.md` — auto-loads for any `*.java` file. +- `.claude/rules/java/testing.md` + `.../testcontainers.md` — test expectations. +- `.claude/rules/java/security.md` — when touching auth/input/external IO. +- The module's `*_MODULE.md` (e.g. `opendaimon-telegram/TELEGRAM_MODULE.md`) — module-specific behavior. + +Your step 1 stays: read these before writing code. Do not paraphrase them into your output — just follow them. ## Discovery tools — prefer over ad-hoc search diff --git a/CLAUDE.md b/CLAUDE.md index 00291fef..46b94efb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,9 @@ ## Subagent delegation -- For non-trivial Java changes in `opendaimon-*` modules (>1 file, requires a test, or touches JPA/Spring config), delegate to the `senior-enterprise-java` subagent. -- Do not spawn a subagent for a one-line fix you can make directly. +- Delegate to `senior-enterprise-java` only when the task meets one of: >=3 Java files changed, a new service/entity/migration, OR both unit and integration test coverage required. For smaller changes handle them in the main loop. +- Never delegate: single-file edits <50 lines, log-driven bug fixes (use `root-cause` skill), docs-only or config-only changes, continuation of in-progress work. +- Before delegating, state in one sentence why the threshold is met. ## Debugging diff --git a/docs/agent-evolution-roadmap.md b/docs/agent-evolution-roadmap.md index 2cae7bc0..670a033e 100644 --- a/docs/agent-evolution-roadmap.md +++ b/docs/agent-evolution-roadmap.md @@ -479,6 +479,17 @@ caching-capable provider. ### Step 2 — Native tool_use first-class +**Status.** In progress — tactical precursor landed separately: `WebTools.webSearch` +now returns an Error-prefixed string when invoked with null/blank `query` +(classified as failure by `ToolObservationClassifier`), so the model gets a +structured retry instruction instead of a success-shaped empty result. This +covers the "surface bad-input as error" principle of Step 2 for the one +tool most commonly mis-called by flaky models. The remainder of Step 2 +(`NATIVE_TOOL_USE` capability gate, decommissioning `RawToolCallParser` as +first-class path, trimming tool discipline from system prompt for capable +models) is still pending. See `SPRING_AI_MODULE.md` § "Empty-arguments +guard on web_search" for the landed behaviour. + **Goal.** Remove reliance on `RawToolCallParser` (XML-in-text) for models that support structured tool calling; keep it only as a fallback for local models that cannot. diff --git a/docs/chat-streaming-disable-toggle.md b/docs/chat-streaming-disable-toggle.md new file mode 100644 index 00000000..f627a95f --- /dev/null +++ b/docs/chat-streaming-disable-toggle.md @@ -0,0 +1,274 @@ +# Feature Toggle — `CHAT_STREAMING_DISABLED` + +## Context + +Streaming is the default transport for LLM responses in this project. +Some deployments (certain REST clients, specific Telegram setups) +prefer **atomic whole-message delivery** — either because streaming +edit-in-place looks glitchy, because intermediate-chunk error recovery +is tricky, or because downstream consumers expect a single JSON +response. + +This toggle forces **`.call()` instead of `.stream()`** on the LLM +transport, across **all** request paths: non-agent chat AND agent-mode. + +Important scope clarification: the toggle changes **LLM transport +only**, not agent progress visibility. Even with toggle ON, Telegram +agent-mode still emits `AgentStreamEvent` per iteration (thinking, +tool-call, observation) — what changes is that each iteration's model +call completes in one HTTP round-trip rather than streamed chunks. + +## Current paths + +### Non-agent path + +`opendaimon-spring-ai/.../service/SpringAIGateway.java` line 202: + +```java +if (chatOptions.stream()) { + return chatService.streamChat(modelConfig, command, chatOptions, messages); +} +return chatService.callChat(modelConfig, command, chatOptions, messages); +``` + +The decision respects `chatOptions.stream()` — set upstream by the +command factory. Our toggle needs to **override this to false** when +enabled. + +### Agent path + +`opendaimon-spring-ai/.../agent/SpringAgentLoopActions.java`, method +`streamAndAggregate()` around line 285. The method does +`chatModel.stream(prompt).collect(...)` unconditionally — it does +**not** consult `chatOptions.stream()`. The agent always streams and +aggregates. + +This means a single toggle check at `SpringAIGateway:202` would affect +non-agent chat only. To cover agent-mode we need a second +integration point inside `streamAndAggregate()`. + +## Desired behaviour with toggle ON + +Non-agent chat: all requests route to `chatService.callChat(...)` +regardless of `chatOptions.stream()`. Response type is +`SpringAIResponse` (not `SpringAIStreamResponse`). + +Agent-mode: each iteration's LLM call uses `chatModel.call(prompt)` +and returns a full `ChatResponse` directly; `AgentStreamEvent` +emission for user-visible progress continues as before. + +Telegram rendering is already polymorphic on `AIResponse` type +(see `TelegramMessageHandlerActions.extractResponseContext()` line +1091 branching on `instanceof SpringAIStreamResponse`). No Telegram +code change required. + +## Feature toggle definition + +`opendaimon-common/.../config/FeatureToggle.java`: + +```java +// In FeatureToggle.Feature: +public static final String CHAT_STREAMING_DISABLED = + "open-daimon.feature.ai.spring-ai.chat-streaming-disabled"; + +// In Toggle enum: +CHAT_STREAMING_DISABLED(Feature.CHAT_STREAMING_DISABLED), +``` + +Default: **false** (streaming remains on). + +## Implementation sketch + +### Integration point A — non-agent path + +`SpringAIGateway.java` line 202: + +```java +// before +if (chatOptions.stream()) { + return chatService.streamChat(modelConfig, command, chatOptions, messages); +} +return chatService.callChat(modelConfig, command, chatOptions, messages); + +// after +boolean streamDisabled = streamingDisabledToggle.isEnabled(); +if (!streamDisabled && chatOptions.stream()) { + return chatService.streamChat(modelConfig, command, chatOptions, messages); +} +return chatService.callChat(modelConfig, command, chatOptions, messages); +``` + +Inject `FeatureToggle` via constructor; wire the new dependency in +`SpringAIAutoConfig` where `SpringAIGateway` is built. + +### Integration point B — agent path + +`SpringAgentLoopActions.streamAndAggregate()` around line 285: + +```java +if (streamingDisabledToggle.isEnabled()) { + ChatResponse response = chatModel.call(prompt); + return wrapAsSingleChunk(response); +} +// existing streaming path — chatModel.stream(prompt).collect(...) +``` + +`wrapAsSingleChunk` returns the `ChatResponse` in whatever shape the +aggregated streaming path returns today (usually a single `ChatResponse` +with metadata and full text) — check the return signature of +`streamAndAggregate()` and match it. + +`AgentStreamEvent` emission for `think` / `tool_call` / `observation` / +`answer` stays unchanged — those happen *outside* `streamAndAggregate()` +in `think()` / `executeTool()` / `observe()` / `answer()` actions. + +Inject the toggle into `SpringAgentLoopActions` via constructor; wire +in `AgentAutoConfig`. + +## Files to modify + +| File | Change | Approx LOC | +|---|---|---| +| `opendaimon-common/.../config/FeatureToggle.java` | Add `CHAT_STREAMING_DISABLED` | ~5 | +| `opendaimon-spring-ai/.../service/SpringAIGateway.java` | Toggle check at line 202 + constructor injection | ~8 | +| `opendaimon-spring-ai/.../config/SpringAIAutoConfig.java` | Wire `FeatureToggle` into gateway | ~3 | +| `opendaimon-spring-ai/.../agent/SpringAgentLoopActions.java` | Branch in `streamAndAggregate()` + constructor injection | ~15 | +| `opendaimon-spring-ai/.../config/AgentAutoConfig.java` | Wire `FeatureToggle` into agent loop actions | ~3 | +| `opendaimon-spring-ai/.../rest/RestChatStreamMessageCommandHandler.java` | Handle non-stream response when toggle on (see Gotcha 1) | ~5–10 | +| `opendaimon-spring-ai/.../service/SpringAIGatewayTest.java` | Three new toggle cases | ~25 | +| `opendaimon-spring-ai/.../agent/SpringAgentLoopActionsTest.java` | Two new agent-path cases | ~25 | +| `opendaimon-spring-ai/SPRING_AI_MODULE.md` | Describe toggle + streaming behaviour | ~20 | +| `docs/feature-toggles.md` | Add toggle entry | ~5 | +| **Total** | | **~115** | + +## Gotchas — confirm during implementation, do not assume + +### 1. REST stream handler polymorphism + +`RestChatStreamMessageCommandHandler.java:114` currently checks +`instanceof SpringAIStreamResponse`. When the toggle is ON and the +underlying path returns `SpringAIResponse`, this `instanceof` branch +is skipped. Two sub-cases: + +- **REST client does not require SSE**: the fallback (non-SSE) path + returns the full JSON response — works as-is. Verify the fallback + exists. +- **REST client requires `text/event-stream`**: if a client negotiates + SSE content-type, returning JSON will break it. In that case, wrap + the `SpringAIResponse` as a single-chunk SSE emission for wire + compatibility. + +Decision: during implementation, inspect +`RestChatStreamMessageCommandHandler.java:114` and a few current +client tests — if SSE is required, add a wrapping shim; otherwise +the fallback is sufficient. Do NOT bundle a larger REST refactor +into this toggle. + +### 2. HTTP read timeout + +Non-streaming holds the HTTP connection open for the full LLM +response. For long reasoning tasks (>30 s of model thinking + +generation) a default WebClient timeout can fire. + +Mitigation: confirm OpenRouter / Spring AI client `readTimeout` is +≥ 120 s. If not configured, add via +`application.yml` / `SpringAIProperties` in the same commit or +document as a follow-up operational risk. + +### 3. OpenRouter reasoning in non-stream mode + +In streaming, reasoning arrives as metadata chunks (see +`SpringAIChatService.streamChat` lines 92–111, currently commented +but structurally present). In non-streaming, reasoning is in the +final `ChatResponse` metadata. + +`AgentTextSanitizer.extractReasoning()` already handles both +(reads from `thinking` / `reasoningContent` keys). No behaviour +change expected — but verify during manual IT that reasoning is +still extracted into `AgentStreamEvent.thinking` correctly when the +toggle is ON. + +### 4. Agent iteration UX under toggle + +Even with toggle ON, per-iteration `AgentStreamEvent` updates +(thinking, tool call, observation) still render in Telegram. The +difference is invisible to the user: each iteration receives its +full model response at once instead of chunk-by-chunk. This is the +intended UX — the toggle changes *transport*, not *progress +visibility*. + +### 5. Telegram rendering — no change needed + +`TelegramMessageHandlerActions.extractResponseContext()` line 1091 +already branches on `instanceof SpringAIStreamResponse` with a +non-stream fallback via single `retrieveMessage()` (line 1111). +Works polymorphically without code changes. + +## Tests + +Follow `.claude/rules/java/testing.md` conventions: JUnit 5 + AssertJ ++ Mockito; naming `shouldDoSomethingWhenCondition`. + +### `SpringAIGatewayTest` + +- `shouldCallChatWhenStreamingDisabledToggleOn` — toggle ON, + `chatOptions.stream()=true`, assert `chatService.callChat` invoked, + `streamChat` never invoked. +- `shouldStreamWhenToggleOffAndStreamRequested` — toggle OFF, stream + flag ON, assert `streamChat` invoked (regression guard). +- `shouldCallChatWhenStreamFlagFalseIndependentOfToggle` — stream + flag OFF always routes to `callChat`, regardless of toggle. + +### `SpringAgentLoopActionsTest` + +- `shouldUseChatModelCallWhenStreamingDisabled` — toggle ON, verify + `streamAndAggregate()` invokes `chatModel.call(prompt)` (not + `.stream()`), returns aggregated response. +- `shouldStreamByDefault` — toggle OFF, verify streaming path + invoked (regression guard). + +### Fixture smoke + +`./mvnw clean verify -pl opendaimon-app -am -Pfixture` — end-to-end +agent flow with toggle ON and OFF. Required to pass before merge. + +## Verification + +1. `./mvnw clean compile -pl opendaimon-spring-ai -am` +2. Unit tests above pass. +3. Fixture smoke in both toggle states. +4. Manual IT via `AgentModeOpenRouterManualIT`: + - Toggle OFF (default): chunks arrive progressively, typing + indicator animates, streaming intact. + - Toggle ON: single model response per iteration (inspect + `SpringAgentLoopActions:285` log line), agent progress events + still emitted, Telegram status transcript updates between + iterations. +5. Documentation update — `SPRING_AI_MODULE.md` + + `docs/feature-toggles.md` in the same commit per `AGENTS.md` + § Documentation maintenance. + +## Scope — NOT in this task + +- Broader REST refactor — only the minimum shim to keep non-stream + response compatible with current clients. +- Any change to `AgentStreamEvent` contract. +- Changes to `chatOptions.stream()` semantics or upstream decision + logic in command factories — toggle overrides at gateway level + only. +- Removal of streaming code paths — they remain as the default + path; toggle adds a parallel non-stream path. + +## Effort / risk + +**~1.5 dev-days. Medium risk.** Risks concentrated in REST +SSE-compatibility (Gotcha 1) and HTTP timeouts (Gotcha 2). Both +are mitigable and surface during implementation, not after. Agent +path change is narrow (single method refactor) but touches +production-critical code — adequate test coverage and fixture +smoke are non-negotiable. + +## Dependencies + +Independent of `TELEGRAM_THINKING_PRESERVE` +(`docs/telegram-thinking-preserve-toggle.md`). Can ship separately. diff --git a/docs/feature-toggles.md b/docs/feature-toggles.md index d01e1b18..f6aa8793 100644 --- a/docs/feature-toggles.md +++ b/docs/feature-toggles.md @@ -49,6 +49,13 @@ Constant names use `SCREAMING_SNAKE_CASE` matching the property semantic: 4. Use the constant in `@ConditionalOnProperty` annotations 5. Document the toggle with a `# FEATURE FLAG` comment in `application.yml` +## Telegram Command Toggles + +| Constant | Property Key | Default | Description | +|---|---|---|---| +| `TelegramCommand.LANGUAGE` | `open-daimon.telegram.commands.language-enabled` | `true` | Enable the `/language` per-user language selection command. | +| `TelegramCommand.THINKING` | `open-daimon.telegram.commands.thinking-enabled` | `true` | Enable the `/thinking` per-user reasoning-visibility command (3 states: SHOW_ALL, HIDE_REASONING, SILENT). See [docs/telegram-thinking-modes.md](telegram-thinking-modes.md). | + ## Default Values All default values live exclusively in `application.yml` — never in Java code, diff --git a/docs/telegram-thinking-modes.md b/docs/telegram-thinking-modes.md new file mode 100644 index 00000000..8c86324b --- /dev/null +++ b/docs/telegram-thinking-modes.md @@ -0,0 +1,167 @@ +# Per-User Thinking Modes — `/thinking` Command + +## Context + +In Telegram agent-mode the status transcript renders the model's reasoning during streaming. +Different users have different preferences — some want full reasoning traces for debugging and +transparency, others want a clean transcript with only tool interactions, and others want the +minimum-distraction experience with no thinking activity visible at all. + +The `/thinking` Telegram command lets each user independently control reasoning visibility via +a three-mode enum. This is a **per-user UX-layer** setting: it changes *rendering only*, not +what the model produces or how the agent iterates. + +## Modes — canonical definitions + +### ✅ Show reasoning (`SHOW_ALL`) + +Full verbosity. `"💭 Thinking..."` placeholder is written on every iteration, then replaced +by the italicised reasoning snippet. When a `tool_call` arrives, the reasoning line is +**preserved above** the tool block with a blank-line separator. Final transcript contains +reasoning, tool blocks and observations for each iteration. + +### 🔕 Tools only (`HIDE_REASONING`) — current default + +`"💭 Thinking..."` placeholder is shown and the reasoning briefly replaces it +(visible mid-stream), but when the `tool_call` arrives the reasoning line is +**overwritten** by the tool block. Final transcript contains only tool blocks and +observations — the reasoning was part of the live stream but did not survive into the +final message. + +### 🤫 Silent mode (`SILENT`) + +No thinking-related rendering **ever**. The `"💭 Thinking..."` placeholder is never +written, and `THINKING` stream events are dropped at the renderer boundary. The status +message only starts accumulating content when the first `tool_call` event arrives. Same +final transcript as `Tools only`; the difference is strictly in the streaming UX. + +### Comparison table + +| Dimension | Show reasoning | Tools only | Silent | +|---|---|---|---| +| `"💭 Thinking..."` placeholder visible during stream | ✅ | ✅ | ❌ | +| Reasoning text visible during stream | ✅ (persists) | ✅ (briefly, then overwritten) | ❌ (never rendered) | +| Reasoning text in final transcript | ✅ (above each tool block) | ❌ | ❌ | +| Tool blocks visible during stream | ✅ | ✅ | ✅ | +| Tool blocks in final transcript | ✅ | ✅ | ✅ | +| Observations in final transcript | ✅ | ✅ | ✅ | +| Final answer | ✅ | ✅ | ✅ | + +Key insight: `Tools only` and `Silent` produce **identical final transcripts** — they differ +only in whether the user sees any thinking-related activity during the stream. `Tools only` +gives "agent is working" feedback (thinking placeholder pulses, reasoning flashes between +tool calls). `Silent` removes that feedback entirely. + +## Data model + +`ThinkingMode User.thinkingMode` (enum, not-null, default `HIDE_REASONING`). + +### Enum + +```java +// opendaimon-common/.../model/ThinkingMode.java +public enum ThinkingMode { + SHOW_ALL, // reasoning persists above tool calls + HIDE_REASONING, // reasoning flashes during stream, then overwritten + SILENT // no thinking rendering at all +} +``` + +### Migration V14 + +`opendaimon-common/src/main/resources/db/migration/core/V14__Replace_thinking_preserve_with_thinking_mode.sql` + +Mapping: `thinking_preserve_enabled = TRUE` → `SHOW_ALL`, `FALSE`/`NULL` → `HIDE_REASONING`. +No user is ever migrated to `SILENT` — opt-in only via `/thinking`. + +## Command flow + +1. User sends `/thinking` → handler loads user, reads current mode, sends inline-button menu + with four buttons: + - "✅ Show reasoning" → callback `THINKING_SHOW_ALL` + - "🔕 Tools only" → callback `THINKING_HIDE_REASONING` + - "🤫 Silent mode" → callback `THINKING_SILENT` + - "❌ Cancel / Close" → callback `THINKING_CANCEL` +2. On `THINKING_SHOW_ALL`: `telegramUserService.updateThinkingMode(id, SHOW_ALL)`; + ack, delete menu, send confirmation. +3. On `THINKING_HIDE_REASONING`: `telegramUserService.updateThinkingMode(id, HIDE_REASONING)`; + ack, delete menu, send confirmation. +4. On `THINKING_SILENT`: `telegramUserService.updateThinkingMode(id, SILENT)`; + ack, delete menu, send confirmation. +5. On `THINKING_CANCEL`: ack and delete menu; no persistence. + +## Runtime rendering + +### SILENT gate — TelegramAgentStreamRenderer + +```java +TelegramUser user = ctx.getTelegramUser(); +if (user != null && user.getThinkingMode() == ThinkingMode.SILENT) { + return new RenderedUpdate.NoOp(); +} +``` + +All subsequent thinking machinery is bypassed for SILENT users. + +### Placeholder skip — TelegramMessageHandlerActions.ensureStatusMessage() + +For SILENT users the `"💭 Thinking..."` placeholder is NOT appended to the status buffer +before sending the initial status message. The status message is still created (so +tool-call updates have a target), but starts empty. + +### Preserve-above logic — TelegramMessageHandlerActions.appendToolCallBlock() + +```java +TelegramUser user = ctx.getTelegramUser(); +boolean preserve = user != null && user.getThinkingMode() == ThinkingMode.SHOW_ALL; +``` + +Only `SHOW_ALL` preserves the reasoning snippet above the tool-call block. +`HIDE_REASONING` and `SILENT` both overwrite (SILENT never had the line to begin with). + +## Files modified + +| File | Change | +|---|---| +| `opendaimon-common/.../model/ThinkingMode.java` | **NEW** — enum with three values | +| `opendaimon-common/.../model/User.java` | Replace `thinkingPreserveEnabled` with `thinkingMode`; `@Enumerated(EnumType.STRING)` | +| `opendaimon-common/src/main/resources/db/migration/core/V14__Replace_thinking_preserve_with_thinking_mode.sql` | **NEW** — migration | +| `opendaimon-telegram/.../service/TelegramUserService.java` | Rename `updateThinkingPreserveEnabled` → `updateThinkingMode(Long, ThinkingMode)` | +| `opendaimon-telegram/.../command/handler/impl/ThinkingTelegramCommandHandler.java` | Rewrite: 3 callback constants + 3 mode buttons + Cancel | +| `opendaimon-telegram/.../service/TelegramAgentStreamRenderer.java` | `renderThinking()` returns `NoOp()` for SILENT users | +| `opendaimon-telegram/.../fsm/TelegramMessageHandlerActions.java` | `ensureStatusMessage()` skips placeholder for SILENT; `appendToolCallBlock()` uses `== SHOW_ALL` | +| `opendaimon-telegram/src/main/resources/messages/telegram_en.properties` | Replace `.label.on/.off` with `.label.show_all/.tools_only/.silent`; add `.current.*` keys | +| `opendaimon-telegram/src/main/resources/messages/telegram_ru.properties` | Same, Russian translations | +| `opendaimon-telegram/.../ThinkingTelegramCommandHandlerTest.java` | Rewrite: three mode-callback tests, three current-mode prompt tests, cancel test | +| `opendaimon-telegram/.../fsm/TelegramMessageHandlerActionsStreamingTest.java` | Update two existing tests; add `shouldSuppressThinkingRenderingInSilentMode` | +| `opendaimon-telegram/TELEGRAM_MODULE.md` | Update per-user thinking section; remove "proposed" annotation from Silent | +| `docs/feature-toggles.md` | Update `/thinking` entry to reference 3 states | + +## Tests + +- `ThinkingTelegramCommandHandlerTest`: + - `shouldPersistShowAllWhenThinkingShowAllCallback` + - `shouldPersistHideReasoningWhenThinkingHideReasoningCallback` + - `shouldPersistSilentWhenThinkingSilentCallback` + - `shouldShowCurrentModeInPromptWhenUserHasShowAll` + - `shouldShowCurrentModeInPromptWhenUserHasToolsOnly` + - `shouldShowCurrentModeInPromptWhenUserHasSilent` + - `shouldDeleteMenuWhenThinkingCancelCallback` +- `TelegramMessageHandlerActionsStreamingTest`: + - `shouldPreserveThinkingAboveToolCallWhenShowAll` + - `shouldOverwriteThinkingWhenToolsOnly` + - `shouldSuppressThinkingRenderingInSilentMode` + +## Verification + +1. `./mvnw clean compile -pl opendaimon-common -am` +2. `./mvnw clean compile -pl opendaimon-telegram -am` +3. `./mvnw test -pl opendaimon-telegram -Dtest=ThinkingTelegramCommandHandlerTest,TelegramMessageHandlerActionsStreamingTest` +4. `./mvnw test -pl opendaimon-common` — Flyway migration V14 validated via Testcontainer + +## Scope — NOT in this task + +- No change to `AgentStreamEvent` shape or semantics. +- No change to how agent iterations work — this is pure rendering. +- No DB backfill of existing users to `SILENT` — opt-in only. +- No rollback migration; Flyway fix-forward only. diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GatewayPassthroughOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GatewayPassthroughOpenRouterManualIT.java index 35221ef7..ff2b49ce 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GatewayPassthroughOpenRouterManualIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GatewayPassthroughOpenRouterManualIT.java @@ -405,7 +405,7 @@ public SpyWebTools(WebClient webClient, String apiKey, String apiUrl) { } @Override - public SearchResult webSearch(String query) { + public Object webSearch(String query) { capturedQueries.add(query); log.info("SpyWebTools.webSearch captured query=[{}]", query); return super.webSearch(query); diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java index f3f703cb..bfe0ef1d 100644 --- a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java @@ -76,6 +76,7 @@ private TelegramCommand() { public static final String MESSAGE = "message-enabled"; public static final String MODEL = "model-enabled"; public static final String MODE = "mode-enabled"; + public static final String THINKING = "thinking-enabled"; } // ── OpenRouter model rotation toggles (prefix-based) ──────── @@ -125,7 +126,8 @@ public enum Toggle { CMD_THREADS(TelegramCommand.PREFIX + "." + TelegramCommand.THREADS), CMD_MESSAGE(TelegramCommand.PREFIX + "." + TelegramCommand.MESSAGE), CMD_MODEL(TelegramCommand.PREFIX + "." + TelegramCommand.MODEL), - CMD_MODE(TelegramCommand.PREFIX + "." + TelegramCommand.MODE); + CMD_MODE(TelegramCommand.PREFIX + "." + TelegramCommand.MODE), + CMD_THINKING(TelegramCommand.PREFIX + "." + TelegramCommand.THINKING); private final String propertyKey; diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/ThinkingMode.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/ThinkingMode.java new file mode 100644 index 00000000..d5257608 --- /dev/null +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/ThinkingMode.java @@ -0,0 +1,18 @@ +package io.github.ngirchev.opendaimon.common.model; + +/** + * Per-user reasoning-visibility mode for the Telegram {@code /thinking} command. + * + *

    + *
  • {@link #SHOW_ALL} — reasoning persists above each tool-call block in the final transcript.
  • + *
  • {@link #HIDE_REASONING} — reasoning flashes during the stream, then gets overwritten by the + * tool-call block (current default).
  • + *
  • {@link #SILENT} — no thinking-related rendering ever: the {@code "💭 Thinking..."} placeholder + * is never written, and {@code THINKING} stream events are dropped at the renderer boundary.
  • + *
+ */ +public enum ThinkingMode { + SHOW_ALL, + HIDE_REASONING, + SILENT +} diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/User.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/User.java index c9e3f012..f1a426b0 100644 --- a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/User.java +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/User.java @@ -67,6 +67,17 @@ public class User extends AbstractEntity implements IUserObject { @Column(name = "agent_mode_enabled") private Boolean agentModeEnabled; + /** + * Per-user thinking-visibility mode. Controls how the model's reasoning is rendered + * during and after streaming in the Telegram status transcript. + * Set explicitly via the {@code /thinking} Telegram command. + * + * @see ThinkingMode + */ + @Enumerated(EnumType.STRING) + @Column(name = "thinking_mode", nullable = false) + private ThinkingMode thinkingMode = ThinkingMode.HIDE_REASONING; + /** * Current active assistant role */ diff --git a/opendaimon-common/src/main/resources/db/migration/core/V13__Add_thinking_preserve_enabled_to_user.sql b/opendaimon-common/src/main/resources/db/migration/core/V13__Add_thinking_preserve_enabled_to_user.sql new file mode 100644 index 00000000..1af8c325 --- /dev/null +++ b/opendaimon-common/src/main/resources/db/migration/core/V13__Add_thinking_preserve_enabled_to_user.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS thinking_preserve_enabled BOOLEAN DEFAULT FALSE; diff --git a/opendaimon-common/src/main/resources/db/migration/core/V14__Replace_thinking_preserve_with_thinking_mode.sql b/opendaimon-common/src/main/resources/db/migration/core/V14__Replace_thinking_preserve_with_thinking_mode.sql new file mode 100644 index 00000000..afd0c9ab --- /dev/null +++ b/opendaimon-common/src/main/resources/db/migration/core/V14__Replace_thinking_preserve_with_thinking_mode.sql @@ -0,0 +1,16 @@ +ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS thinking_mode VARCHAR(20); + +UPDATE "user" + SET thinking_mode = CASE + WHEN thinking_preserve_enabled = TRUE THEN 'SHOW_ALL' + ELSE 'HIDE_REASONING' + END + WHERE thinking_mode IS NULL; + +ALTER TABLE "user" + ALTER COLUMN thinking_mode SET NOT NULL, + ALTER COLUMN thinking_mode SET DEFAULT 'HIDE_REASONING'; + +ALTER TABLE "user" + DROP COLUMN IF EXISTS thinking_preserve_enabled; diff --git a/opendaimon-spring-ai/SPRING_AI_MODULE.md b/opendaimon-spring-ai/SPRING_AI_MODULE.md index 39b1a3a9..bf51fdfe 100644 --- a/opendaimon-spring-ai/SPRING_AI_MODULE.md +++ b/opendaimon-spring-ai/SPRING_AI_MODULE.md @@ -609,11 +609,32 @@ The codes are public constants on `WebTools`; downstream test fixtures and `z-ai/glm-4.5v`) emit a `web_search` `tool_call` with empty arguments — Spring AI deserialises this as `query=null` and invokes `WebTools.webSearch(null)`. `Map.of("q", query, …)` would then throw NPE, and Spring AI converts the -exception into the textual `"Exception occurred in tool: web_search (…)"` -string (now recognised by `isTextualToolFailure`). To avoid even reaching that -path, `webSearch` returns an empty `SearchResult` early on `query == null` or -`query.isBlank()`, logging the event at WARN. The result is a valid JSON the -model handles gracefully. +exception into the textual `"Exception occurred in tool: web_search (…)"` string. + +`webSearch` handles this case explicitly: when `query` is null or blank, it +returns an **Error-prefixed string** rather than a success-shaped empty +`SearchResult`. The return signature is `Object` so the method can yield +either a `SearchResult` (success / API-key not configured) or a `String` +(structured error for bad input). The error text is: + +> `"Error: argument 'query' is required and must not be blank. Retry +> web_search with a non-empty 'query' field containing the search terms. +> Example arguments: {"query": "…"}"` + +Rationale: a success-shaped `{"query":"","hits":[]}` is indistinguishable +from "search ran, 0 results" and the model therefore cannot self-correct. +An Error-prefixed string is matched by +`ToolObservationClassifier.isTextualToolFailure()` and surfaced to the +model as a failure observation with explicit retry instructions, which +lets it self-correct on the next iteration (put a non-empty `query` into +the tool_call arguments). Aligns with the design decision recorded in +`docs/agent-evolution-roadmap.md` Step 2 — "treat structural tool-use +problems as errors worth surfacing, not silent fallbacks". + +The `apiKey` not-configured branch (server-side misconfiguration, not a +model-side mistake) still returns an empty `SearchResult` so we do not +nudge the model into a retry loop for a problem only the operator can +fix. ### History recovery from primary store — `SummarizingChatMemory` diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/WebTools.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/WebTools.java index cc2050de..d8a789c8 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/WebTools.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/WebTools.java @@ -52,16 +52,19 @@ public class WebTools { name = "web_search", description = "Search the web for recent, factual information and return top results with URLs." ) - public SearchResult webSearch(String query) { + public Object webSearch(String query) { if (apiKey == null || apiKey.isBlank()) { log.warn("WebTools.webSearch: Serper API key is not configured. Web search disabled. Returning empty result for query=[{}].", query); return new SearchResult(query, List.of()); } if (query == null || query.isBlank()) { - log.warn("WebTools.webSearch: query is null/blank — skipping. " - + "Likely the model emitted an empty tool_call arguments object."); - return new SearchResult(query == null ? "" : query, List.of()); + log.warn("WebTools.webSearch: query is null/blank — returning structured error. " + + "The model emitted an empty tool_call arguments object; the error-shaped observation " + + "will be classified as a failure so the model can self-correct on the next iteration."); + return "Error: argument 'query' is required and must not be blank. " + + "Retry web_search with a non-empty 'query' field containing the search terms. " + + "Example arguments: {\"query\": \"russian theater cyprus 2026\"}"; } Map body = Map.of( diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/WebToolsTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/WebToolsTest.java index 522aca02..d1a956d9 100644 --- a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/WebToolsTest.java +++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/WebToolsTest.java @@ -56,7 +56,7 @@ void setUp() { @Test void webSearch_whenApiKeyBlank_returnsEmptyResult() { WebTools noKeyTools = new WebTools(webClient, " ", "https://example.com"); - var result = noKeyTools.webSearch("query"); + var result = (WebTools.SearchResult) noKeyTools.webSearch("query"); assertNotNull(result); assertEquals("query", result.query()); assertTrue(result.hits().isEmpty()); @@ -66,35 +66,38 @@ void webSearch_whenApiKeyBlank_returnsEmptyResult() { @Test void webSearch_whenApiKeyNull_returnsEmptyResult() { WebTools noKeyTools = new WebTools(webClient, null, "https://example.com"); - var result = noKeyTools.webSearch("query"); + var result = (WebTools.SearchResult) noKeyTools.webSearch("query"); assertNotNull(result); assertTrue(result.hits().isEmpty()); } @Test - void shouldReturnEmptyResultWhenQueryIsNull() { + void shouldReturnErrorStringWhenQueryIsNull() { // Spring AI deserialises a tool_call with empty arguments ({}) as query=null - // and invokes webSearch(null). Without the guard, Map.of("q", null, ...) blows up - // with NPE and Spring AI converts that into an unhelpful "Exception occurred …" - // string. The guard returns an empty SearchResult early — no HTTP call is made. + // and invokes webSearch(null). Instead of a success-shaped empty SearchResult + // (which the model cannot distinguish from "search ran, 0 results"), we return + // an Error-prefixed string so ToolObservationClassifier flags it as a tool + // failure and the model receives an explicit instruction to retry with a + // non-empty 'query' argument. var result = webTools.webSearch(null); - assertNotNull(result); - assertEquals("", result.query()); - assertTrue(result.hits().isEmpty()); + assertThat(result).isInstanceOf(String.class); + assertThat((String) result).startsWith("Error: "); + assertThat((String) result).contains("query"); + assertThat((String) result).contains("required"); verify(webClient, never()).post(); } @Test - void shouldReturnEmptyResultWhenQueryIsBlank() { - // Same guard as the null case — a whitespace-only query is equally useless. - // The original (non-null) input is echoed back in SearchResult.query() so the - // model can still see what it asked for. + void shouldReturnErrorStringWhenQueryIsBlank() { + // Same rationale as the null case: a whitespace-only query is also a bad-input + // signal from the model. Returning an Error-prefixed string lets the classifier + // and the downstream LLM distinguish this from a valid-but-empty search. var result = webTools.webSearch(" "); - assertNotNull(result); - assertEquals(" ", result.query()); - assertTrue(result.hits().isEmpty()); + assertThat(result).isInstanceOf(String.class); + assertThat((String) result).startsWith("Error: "); + assertThat((String) result).contains("query"); verify(webClient, never()).post(); } diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index e6b228c7..d79b6876 100644 --- a/opendaimon-telegram/TELEGRAM_MODULE.md +++ b/opendaimon-telegram/TELEGRAM_MODULE.md @@ -537,12 +537,58 @@ If the model emits `AgentStreamEvent.thinking` with non-empty reasoning: - Replace the trailing `💭 Thinking...` line (or prior reasoning overlay) with the new reasoning text wrapped in `` — edit throttled to once per second. -- When the iteration ends with a `toolCall`, the reasoning overlay is replaced by the - tool-call block (step 2). Visibility of the reasoning state is guaranteed by the paced +- When the iteration ends with a `toolCall`, the reasoning overlay is **replaced** by the + tool-call block (step 2) by default. Visibility of the reasoning state is guaranteed by the paced flush of the tool-call edit — the user sees the reasoning for at least one throttle window before the tool-call block overwrites it. +- **Per-user `/thinking` command**: each user can control reasoning visibility by sending `/thinking` + and selecting one of three modes. The mode is persisted in `User.thinkingMode` (DB column + `thinking_mode`, enum `ThinkingMode`). Runtime check is in `appendToolCallBlock()` via + `ctx.getTelegramUser().getThinkingMode() == ThinkingMode.SHOW_ALL`. + See [docs/telegram-thinking-modes.md](../docs/telegram-thinking-modes.md). - If the iteration turns into a final answer, see "Final answer transition" below. +#### Thinking rendering modes + +The `/thinking` command is the UX switch for **three** reasoning-visibility modes. + +**✅ Show reasoning (`SHOW_ALL`)** — `💭 Thinking...` placeholder appears on every +iteration, reasoning text replaces it, and when the `tool_call` arrives the reasoning +line is **preserved above** the tool block with a blank-line separator. The final +transcript carries reasoning + tool blocks + observations for every iteration. + +**🔕 Tools only (`HIDE_REASONING`) — current default.** `💭 Thinking...` placeholder +is shown and reasoning briefly replaces it during the stream, but when the `tool_call` +arrives the reasoning line is **overwritten** by the tool block. Final transcript +contains only tool blocks and observations — reasoning was part of the live stream but +did not survive into the final message. + +**🤫 Silent mode (`SILENT`)** — no thinking-related rendering ever. The +`💭 Thinking...` placeholder is never written, and `THINKING` stream events are +dropped at the renderer boundary (`TelegramAgentStreamRenderer.renderThinking()` +returns `NoOp()` for SILENT users). The status message starts accumulating content +only when the first `tool_call` arrives. Same final transcript as `Tools only`; the +difference is strictly in the streaming UX (no visible activity between tool calls). + +##### Comparison across modes + +| Dimension | Show reasoning | Tools only | Silent | +|---|---|---|---| +| `💭 Thinking...` placeholder visible during stream | ✅ | ✅ | ❌ | +| Reasoning text visible during stream | ✅ (persists) | ✅ (briefly, then overwritten) | ❌ (never rendered) | +| Reasoning text in final transcript | ✅ (above each tool block) | ❌ | ❌ | +| Tool blocks visible during stream | ✅ | ✅ | ✅ | +| Tool blocks in final transcript | ✅ | ✅ | ✅ | +| Observations in final transcript | ✅ | ✅ | ✅ | +| Final answer | ✅ | ✅ | ✅ | + +Key insight: `Tools only` and `Silent` produce **identical final transcripts** — +they differ only in whether the user sees any thinking-related activity during +the stream. `Tools only` gives "agent is working" feedback (thinking placeholder +pulses, reasoning flashes between tool calls). `Silent` removes that feedback +entirely. The choice is strictly a streaming-UX preference, not an information +tradeoff. + ### Final answer transition (tentative + rollback) Final-answer detection is **heuristic**, not driven by a single reliable event. The model may emit diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java index a528fdf8..0af0636c 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java @@ -26,6 +26,7 @@ public class TelegramCommand implements IChatCommand { public static final String LANGUAGE = "/language"; public static final String MODEL = "/model"; public static final String MODE = "/mode"; + public static final String THINKING = "/thinking"; public static final String MODEL_KEYBOARD_PREFIX = "🤖"; public static final String CONTEXT_KEYBOARD_PREFIX = "💬"; diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandler.java new file mode 100644 index 00000000..cf2111f2 --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandler.java @@ -0,0 +1,206 @@ +package io.github.ngirchev.opendaimon.telegram.command.handler.impl; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage; +import org.telegram.telegrambots.meta.api.objects.CallbackQuery; +import org.telegram.telegrambots.meta.api.objects.Message; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; +import io.github.ngirchev.opendaimon.common.command.ICommand; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; +import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; +import io.github.ngirchev.opendaimon.telegram.TelegramBot; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType; +import io.github.ngirchev.opendaimon.telegram.command.handler.AbstractTelegramCommandHandlerWithResponseSend; +import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramCommandHandlerException; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.service.TelegramBotMenuService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; +import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; + +import java.util.List; + +@Slf4j +public class ThinkingTelegramCommandHandler extends AbstractTelegramCommandHandlerWithResponseSend { + + private static final String CALLBACK_PREFIX = "THINKING_"; + private static final String CALLBACK_SHOW_ALL = CALLBACK_PREFIX + "SHOW_ALL"; + private static final String CALLBACK_HIDE_REASONING = CALLBACK_PREFIX + "HIDE_REASONING"; + private static final String CALLBACK_SILENT = CALLBACK_PREFIX + "SILENT"; + private static final String CALLBACK_CANCEL = CALLBACK_PREFIX + "CANCEL"; + + private final TelegramUserService telegramUserService; + private final TelegramBotMenuService menuService; + + public ThinkingTelegramCommandHandler(ObjectProvider telegramBotProvider, + TypingIndicatorService typingIndicatorService, + MessageLocalizationService messageLocalizationService, + TelegramUserService telegramUserService, + TelegramBotMenuService menuService) { + super(telegramBotProvider, typingIndicatorService, messageLocalizationService); + this.telegramUserService = telegramUserService; + this.menuService = menuService; + } + + @Override + public String getSupportedCommandText(String languageCode) { + return messageLocalizationService.getMessage("telegram.command.thinking.desc", languageCode); + } + + @Override + protected boolean shouldShowTypingIndicator(TelegramCommand command) { + return false; + } + + @Override + public boolean canHandle(ICommand command) { + if (!(command instanceof TelegramCommand telegramCommand)) { + return false; + } + if (telegramCommand.update().hasCallbackQuery()) { + CallbackQuery cq = telegramCommand.update().getCallbackQuery(); + return cq.getData() != null && cq.getData().startsWith(CALLBACK_PREFIX); + } + var commandType = command.commandType(); + return commandType != null + && commandType.command() != null + && commandType.command().equals(TelegramCommand.THINKING); + } + + @Override + public String handleInner(TelegramCommand command) { + if (command.update().hasCallbackQuery()) { + handleCallbackQuery(command); + return null; + } + Message message = command.update().getMessage(); + if (message == null) { + throw new TelegramCommandHandlerException(command.telegramId(), "Message is required for thinking command"); + } + TelegramUser user = telegramUserService.getOrCreateUser(message.getFrom()); + ThinkingMode currentMode = user.getThinkingMode() != null ? user.getThinkingMode() : ThinkingMode.HIDE_REASONING; + String currentLabel = thinkingModeLabel(currentMode, command.languageCode()); + String currentMsg = messageLocalizationService.getMessage("telegram.thinking.current", command.languageCode(), currentLabel); + sendThinkingMenu(command.telegramId(), command.languageCode(), currentMsg); + return null; + } + + private void handleCallbackQuery(TelegramCommand command) { + CallbackQuery cq = command.update().getCallbackQuery(); + String callbackData = cq.getData(); + log.info("ThinkingTelegramCommandHandler callback: telegramId={}, data={}", + cq.getFrom() != null ? cq.getFrom().getId() : null, callbackData); + if (callbackData == null || !callbackData.startsWith(CALLBACK_PREFIX)) { + throw new TelegramCommandHandlerException(command.telegramId(), "Invalid callback data"); + } + if (CALLBACK_CANCEL.equals(callbackData)) { + ackCallback(cq.getId(), ""); + deleteMenuMessage(command.telegramId(), cq); + return; + } + if (CALLBACK_SHOW_ALL.equals(callbackData)) { + telegramUserService.updateThinkingMode(cq.getFrom().getId(), ThinkingMode.SHOW_ALL); + String label = messageLocalizationService.getMessage("telegram.thinking.label.show_all", command.languageCode()); + String updatedMsg = messageLocalizationService.getMessage("telegram.thinking.updated", command.languageCode(), label); + ackCallback(cq.getId(), updatedMsg); + deleteMenuMessage(command.telegramId(), cq); + sendConfirmationMessage(command.telegramId(), updatedMsg); + return; + } + if (CALLBACK_HIDE_REASONING.equals(callbackData)) { + telegramUserService.updateThinkingMode(cq.getFrom().getId(), ThinkingMode.HIDE_REASONING); + String label = messageLocalizationService.getMessage("telegram.thinking.label.tools_only", command.languageCode()); + String updatedMsg = messageLocalizationService.getMessage("telegram.thinking.updated", command.languageCode(), label); + ackCallback(cq.getId(), updatedMsg); + deleteMenuMessage(command.telegramId(), cq); + sendConfirmationMessage(command.telegramId(), updatedMsg); + return; + } + if (CALLBACK_SILENT.equals(callbackData)) { + telegramUserService.updateThinkingMode(cq.getFrom().getId(), ThinkingMode.SILENT); + String label = messageLocalizationService.getMessage("telegram.thinking.label.silent", command.languageCode()); + String updatedMsg = messageLocalizationService.getMessage("telegram.thinking.updated", command.languageCode(), label); + ackCallback(cq.getId(), updatedMsg); + deleteMenuMessage(command.telegramId(), cq); + sendConfirmationMessage(command.telegramId(), updatedMsg); + return; + } + ackCallback(cq.getId(), "❌"); + sendErrorMessage(command.telegramId(), messageLocalizationService.getMessage("telegram.thinking.unknown", command.languageCode())); + } + + private void sendThinkingMenu(Long chatId, String languageCode, String currentMsg) { + try { + String labelShowAll = messageLocalizationService.getMessage("telegram.thinking.label.show_all", languageCode); + String labelToolsOnly = messageLocalizationService.getMessage("telegram.thinking.label.tools_only", languageCode); + String labelSilent = messageLocalizationService.getMessage("telegram.thinking.label.silent", languageCode); + String closeLabel = messageLocalizationService.getMessage("telegram.thinking.close", languageCode); + InlineKeyboardMarkup markup = new InlineKeyboardMarkup(List.of( + List.of(button(labelShowAll, CALLBACK_SHOW_ALL)), + List.of(button(labelToolsOnly, CALLBACK_HIDE_REASONING)), + List.of(button(labelSilent, CALLBACK_SILENT)), + List.of(button(closeLabel, CALLBACK_CANCEL)) + )); + String selectText = messageLocalizationService.getMessage("telegram.thinking.select", languageCode); + SendMessage msg = new SendMessage(chatId.toString(), currentMsg + "\n\n" + selectText); + msg.setReplyMarkup(markup); + telegramBotProvider.getObject().execute(msg); + } catch (Exception e) { + throw new TelegramCommandHandlerException("Failed to send thinking menu", e); + } + } + + private InlineKeyboardButton button(String label, String callbackData) { + InlineKeyboardButton button = new InlineKeyboardButton(label); + button.setCallbackData(callbackData); + return button; + } + + private String thinkingModeLabel(ThinkingMode mode, String languageCode) { + return switch (mode) { + case SHOW_ALL -> messageLocalizationService.getMessage("telegram.thinking.label.show_all", languageCode); + case HIDE_REASONING -> messageLocalizationService.getMessage("telegram.thinking.label.tools_only", languageCode); + case SILENT -> messageLocalizationService.getMessage("telegram.thinking.label.silent", languageCode); + }; + } + + private void ackCallback(String callbackQueryId, String text) { + try { + AnswerCallbackQuery ack = new AnswerCallbackQuery(); + ack.setCallbackQueryId(callbackQueryId); + ack.setText(text); + ack.setShowAlert(false); + telegramBotProvider.getObject().execute(ack); + } catch (Exception e) { + throw new TelegramCommandHandlerException("Failed to ack callback", e); + } + } + + /** + * Posts a persistent confirmation message into the chat so the user sees the + * selected thinking mode in conversation history (not just as a transient toast). + */ + private void sendConfirmationMessage(Long chatId, String text) { + try { + SendMessage msg = new SendMessage(chatId.toString(), text); + telegramBotProvider.getObject().execute(msg); + } catch (Exception e) { + log.warn("Failed to send thinking confirmation message: {}", e.getMessage()); + } + } + + private void deleteMenuMessage(Long chatId, CallbackQuery callbackQuery) { + if (callbackQuery.getMessage() instanceof Message menuMessage) { + try { + telegramBotProvider.getObject().execute( + new DeleteMessage(chatId.toString(), menuMessage.getMessageId())); + } catch (Exception e) { + log.warn("Failed to delete thinking menu message: {}", e.getMessage()); + } + } + } +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java index a8109322..0f0893c9 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java @@ -20,6 +20,7 @@ import io.github.ngirchev.opendaimon.common.model.OpenDaimonMessage; import io.github.ngirchev.opendaimon.common.model.RequestType; import io.github.ngirchev.opendaimon.common.model.ResponseStatus; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; import io.github.ngirchev.opendaimon.common.service.AIGateway; import io.github.ngirchev.opendaimon.common.service.AIGatewayRegistry; import io.github.ngirchev.opendaimon.common.service.AIUtils; @@ -68,6 +69,12 @@ *

Error handling: actions catch expected exceptions and set error info on context * rather than throwing. The FSM routes to ERROR terminal state, and the handler * dispatches to the appropriate error handling method. + * + *

Construction: manually instantiated in ~8 sites (prod auto-config, unit + * tests, fixture IT config) because this class is not a Spring-scanned bean. When + * changing the constructor signature, search for {@code new TelegramMessageHandlerActions} + * across the module and {@code opendaimon-app/src/it/java} to update every site — + * missing one produces a compile error only discovered at full build time. */ @Slf4j @RequiredArgsConstructor @@ -650,7 +657,15 @@ private void ensureStatusMessage(MessageHandlerContext ctx) { return; } Long chatId = ctx.getCommand().telegramId(); - ctx.getStatusBuffer().append(STATUS_THINKING_LINE); + TelegramUser user = ctx.getTelegramUser(); + boolean silent = user != null && user.getThinkingMode() == ThinkingMode.SILENT; + log.info("ensureStatusMessage: telegramId={}, thinkingMode={}, silent={}", + user != null ? user.getTelegramId() : null, + user != null ? user.getThinkingMode() : "null-user", + silent); + if (!silent) { + ctx.getStatusBuffer().append(STATUS_THINKING_LINE); + } // Seed iteration 0 so the first null-content THINKING event isn't treated as a // rollover — otherwise the renderer would duplicate the thinking line. A new // AppendFreshThinking still fires when iteration 1 starts. @@ -705,11 +720,30 @@ private Mono appendToolCallBlock(MessageHandlerContext ctx, String toolNam // before the tool-call block overwrites it. Without that pacing, a model that // emits a structured tool call without preceding text would replace "💭 Thinking..." // within the same tick and the user would never see the thinking state at all. + // + // When the per-user thinking-preserve flag is ON (set via /thinking command), + // the reasoning line that arrived between `cut` and the current buffer end is + // kept above the tool-call block so the user can read + // "model thought → called that tool" in the final message. + TelegramUser user = ctx.getTelegramUser(); + boolean preserve = user != null && user.getThinkingMode() == ThinkingMode.SHOW_ALL; + log.info("appendToolCallBlock: telegramId={}, thinkingMode={}, preserveReasoningAbove={}", + user != null ? user.getTelegramId() : null, + user != null ? user.getThinkingMode() : "null-user", + preserve); StringBuilder buf = ctx.getStatusBuffer(); int lastBoundary = buf.lastIndexOf("\n\n"); int cut = lastBoundary >= 0 ? lastBoundary + 2 : 0; - buf.setLength(cut); - buf.append(blockBody); + if (preserve) { + // Preserve the reasoning snippet. Ensure the block starts on its own paragraph. + if (buf.length() > cut && buf.charAt(buf.length() - 1) != '\n') { + buf.append("\n\n"); + } + buf.append(blockBody); + } else { + buf.setLength(cut); + buf.append(blockBody); + } rotateStatusIfNeeded(ctx); return pacedForceFlushStatus(ctx); } diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java index 0496dea4..5b8af760 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java @@ -118,6 +118,19 @@ public ModeTelegramCommandHandler modeTelegramCommandHandler( typingIndicatorService, messageLocalizationService, telegramUserService); } + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = FeatureToggle.TelegramCommand.PREFIX, name = FeatureToggle.TelegramCommand.THINKING, havingValue = "true", matchIfMissing = true) + public ThinkingTelegramCommandHandler thinkingTelegramCommandHandler( + ObjectProvider telegramBotProvider, + TypingIndicatorService typingIndicatorService, + MessageLocalizationService messageLocalizationService, + TelegramUserService telegramUserService, + TelegramBotMenuService telegramBotMenuService) { + return new ThinkingTelegramCommandHandler(telegramBotProvider, + typingIndicatorService, messageLocalizationService, telegramUserService, telegramBotMenuService); + } + @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = FeatureToggle.TelegramCommand.PREFIX, name = FeatureToggle.TelegramCommand.NEW_THREAD, havingValue = "true", matchIfMissing = true) diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamRenderer.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamRenderer.java index 0cfb4b7f..abd03e94 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamRenderer.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamRenderer.java @@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.ngirchev.opendaimon.common.agent.AgentStreamEvent; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import lombok.RequiredArgsConstructor; /** @@ -45,6 +47,10 @@ public RenderedUpdate render(AgentStreamEvent event, MessageHandlerContext ctx) } private RenderedUpdate renderThinking(AgentStreamEvent event, MessageHandlerContext ctx) { + TelegramUser user = ctx.getTelegramUser(); + if (user != null && user.getThinkingMode() == ThinkingMode.SILENT) { + return new RenderedUpdate.NoOp(); + } String content = event.content(); if (content == null || content.isBlank()) { // Placeholder "THINKING" marker — fires at the start of each iteration. diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java index 57e0265d..e8df0a66 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java @@ -9,6 +9,7 @@ import io.github.ngirchev.opendaimon.bulkhead.service.IUserObject; import io.github.ngirchev.opendaimon.bulkhead.service.IUserService; import io.github.ngirchev.opendaimon.common.model.AssistantRole; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; import io.github.ngirchev.opendaimon.common.service.AssistantRoleService; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import io.github.ngirchev.opendaimon.telegram.model.TelegramUserSession; @@ -137,6 +138,23 @@ public TelegramUser updateLanguageCode(Long telegramId, String languageCode) { return telegramUserRepository.save(user); } + /** + * Updates the per-user thinking-visibility mode. + * + * @param telegramId Telegram user id + * @param thinkingMode new mode — {@code SHOW_ALL}, {@code HIDE_REASONING}, or {@code SILENT} + */ + @Transactional + public void updateThinkingMode(Long telegramId, ThinkingMode thinkingMode) { + TelegramUser user = telegramUserRepository.findByTelegramId(telegramId) + .orElseThrow(() -> new RuntimeException(USER_NOT_FOUND)); + user.setThinkingMode(thinkingMode); + OffsetDateTime now = OffsetDateTime.now(); + user.setUpdatedAt(now); + user.setLastActivityAt(now); + telegramUserRepository.save(user); + } + /** * Updates the per-user agent mode flag. * diff --git a/opendaimon-telegram/src/main/resources/messages/telegram_en.properties b/opendaimon-telegram/src/main/resources/messages/telegram_en.properties index 4b439277..ba21c25d 100644 --- a/opendaimon-telegram/src/main/resources/messages/telegram_en.properties +++ b/opendaimon-telegram/src/main/resources/messages/telegram_en.properties @@ -87,3 +87,15 @@ telegram.mode.label.regular=Regular mode telegram.mode.updated=Mode switched: {0} telegram.mode.close=\u274C Cancel / Close telegram.mode.unknown=Unknown mode +telegram.command.thinking.desc=/thinking - configure reasoning visibility +telegram.thinking.current=Current setting: {0} +telegram.thinking.select=Choose reasoning visibility: +telegram.thinking.updated=Reasoning visibility updated: {0} +telegram.thinking.label.show_all=\u2705 Show reasoning +telegram.thinking.label.tools_only=\uD83D\uDD15 Tools only +telegram.thinking.label.silent=\uD83E\uDD2B Silent mode +telegram.thinking.current.show_all=Show reasoning +telegram.thinking.current.tools_only=Tools only +telegram.thinking.current.silent=Silent mode +telegram.thinking.close=\u274C Cancel / Close +telegram.thinking.unknown=Unknown option diff --git a/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties b/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties index 3fa3e32f..e47a705f 100644 --- a/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties +++ b/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties @@ -87,3 +87,15 @@ telegram.mode.label.regular=Обычный режим telegram.mode.updated=Режим изменён: {0} telegram.mode.close=\u274C Отмена / закрыть telegram.mode.unknown=Неизвестный режим +telegram.command.thinking.desc=/thinking - настройка отображения рассуждений +telegram.thinking.current=Текущая настройка: {0} +telegram.thinking.select=Выберите режим отображения рассуждений: +telegram.thinking.updated=Режим отображения рассуждений изменён: {0} +telegram.thinking.label.show_all=\u2705 Показывать рассуждения +telegram.thinking.label.tools_only=\uD83D\uDD15 Только инструменты +telegram.thinking.label.silent=\uD83E\uDD2B Тихий режим +telegram.thinking.current.show_all=Показывать рассуждения +telegram.thinking.current.tools_only=Только инструменты +telegram.thinking.current.silent=Тихий режим +telegram.thinking.close=\u274C Отмена / закрыть +telegram.thinking.unknown=Неизвестная опция diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandlerTest.java new file mode 100644 index 00000000..09006997 --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandlerTest.java @@ -0,0 +1,355 @@ +package io.github.ngirchev.opendaimon.telegram.command.handler.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage; +import org.telegram.telegrambots.meta.api.objects.CallbackQuery; +import org.telegram.telegrambots.meta.api.objects.Message; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; +import io.github.ngirchev.opendaimon.common.command.ICommand; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; +import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; +import io.github.ngirchev.opendaimon.telegram.TelegramBot; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType; +import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramCommandHandlerException; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.service.TelegramBotMenuService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; +import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; +import org.springframework.beans.factory.ObjectProvider; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ThinkingTelegramCommandHandlerTest { + + private static final Long CHAT_ID = 100500L; + private static final Long USER_ID = 123L; + + @Mock private ObjectProvider telegramBotProvider; + @Mock private TelegramBot telegramBot; + @Mock private TypingIndicatorService typingIndicatorService; + @Mock private MessageLocalizationService messageLocalizationService; + @Mock private TelegramUserService telegramUserService; + @Mock private TelegramBotMenuService telegramBotMenuService; + + private ThinkingTelegramCommandHandler handler; + + @BeforeEach + void setUp() { + when(telegramBotProvider.getObject()).thenReturn(telegramBot); + when(messageLocalizationService.getMessage(eq("telegram.command.thinking.desc"), anyString())) + .thenReturn("/thinking - configure reasoning visibility"); + when(messageLocalizationService.getMessage(eq("telegram.thinking.current"), anyString(), anyString())) + .thenAnswer(inv -> "Current setting: " + inv.getArgument(2)); + when(messageLocalizationService.getMessage(eq("telegram.thinking.select"), anyString())) + .thenReturn("Choose reasoning visibility:"); + when(messageLocalizationService.getMessage(eq("telegram.thinking.label.show_all"), anyString())) + .thenReturn("Show reasoning"); + when(messageLocalizationService.getMessage(eq("telegram.thinking.label.tools_only"), anyString())) + .thenReturn("Tools only"); + when(messageLocalizationService.getMessage(eq("telegram.thinking.label.silent"), anyString())) + .thenReturn("Silent mode"); + when(messageLocalizationService.getMessage(eq("telegram.thinking.updated"), anyString(), anyString())) + .thenAnswer(inv -> "Reasoning visibility updated: " + inv.getArgument(2)); + when(messageLocalizationService.getMessage(eq("telegram.thinking.close"), anyString())) + .thenReturn("Cancel / Close"); + when(messageLocalizationService.getMessage(eq("telegram.thinking.unknown"), anyString())) + .thenReturn("Unknown option"); + handler = new ThinkingTelegramCommandHandler( + telegramBotProvider, typingIndicatorService, messageLocalizationService, telegramUserService, telegramBotMenuService); + } + + @Test + void canHandle_whenTelegramCommandWithThinkingCommand_thenTrue() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + assertTrue(handler.canHandle(command)); + } + + @Test + void canHandle_whenNotTelegramCommand_thenFalse() { + assertFalse(handler.canHandle(mock(ICommand.class))); + } + + @Test + void canHandle_whenCallbackQueryWithThinkingPrefix_thenTrue() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("THINKING_SHOW_ALL"); + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + assertTrue(handler.canHandle(command)); + } + + @Test + void canHandle_whenCallbackQueryWithOtherPrefix_thenFalse() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("LANG_ru"); + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + assertFalse(handler.canHandle(command)); + } + + @Test + void canHandle_whenCommandTypeNull_thenFalse() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, null, update); + assertFalse(handler.canHandle(command)); + } + + @Test + void handleInner_whenMessageNull_thenThrows() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + when(update.getMessage()).thenReturn(null); + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + assertThrows(TelegramCommandHandlerException.class, () -> handler.handleInner(command)); + } + + @Test + void handleInner_whenPlainCommand_thenSendsCurrentSettingAndFourButtonMenu() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + Message message = mock(Message.class); + User from = mock(User.class); + when(update.getMessage()).thenReturn(message); + when(message.getFrom()).thenReturn(from); + + TelegramUser telegramUser = new TelegramUser(); + telegramUser.setId(1L); + telegramUser.setThinkingMode(ThinkingMode.HIDE_REASONING); + when(telegramUserService.getOrCreateUser(from)).thenReturn(telegramUser); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + command.languageCode("en"); + + handler.handleInner(command); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(SendMessage.class); + verify(telegramBot).execute(messageCaptor.capture()); + SendMessage sentMessage = messageCaptor.getValue(); + assertEquals(CHAT_ID.toString(), sentMessage.getChatId()); + assertTrue(sentMessage.getText().contains("Current setting")); + assertTrue(sentMessage.getText().contains("Choose reasoning visibility")); + + InlineKeyboardMarkup markup = (InlineKeyboardMarkup) sentMessage.getReplyMarkup(); + assertNotNull(markup); + // 4 rows: show_all, tools_only, silent, cancel + assertEquals(4, markup.getKeyboard().size()); + assertEquals("THINKING_SHOW_ALL", markup.getKeyboard().get(0).get(0).getCallbackData()); + assertEquals("THINKING_HIDE_REASONING", markup.getKeyboard().get(1).get(0).getCallbackData()); + assertEquals("THINKING_SILENT", markup.getKeyboard().get(2).get(0).getCallbackData()); + assertEquals("THINKING_CANCEL", markup.getKeyboard().get(3).get(0).getCallbackData()); + } + + @Test + void handle_whenPlainCommand_doesNotStartTyping() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + Message message = mock(Message.class); + User from = mock(User.class); + when(update.getMessage()).thenReturn(message); + when(message.getFrom()).thenReturn(from); + + TelegramUser telegramUser = new TelegramUser(); + telegramUser.setId(1L); + telegramUser.setThinkingMode(ThinkingMode.HIDE_REASONING); + when(telegramUserService.getOrCreateUser(from)).thenReturn(telegramUser); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + command.languageCode("en"); + + handler.handle(command); + + verify(typingIndicatorService, never()).startTyping(CHAT_ID); + verify(typingIndicatorService, never()).stopTyping(CHAT_ID); + } + + @Test + void shouldShowCurrentModeInPromptWhenUserHasShowAll() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + Message message = mock(Message.class); + User from = mock(User.class); + when(update.getMessage()).thenReturn(message); + when(message.getFrom()).thenReturn(from); + + TelegramUser user = new TelegramUser(); + user.setThinkingMode(ThinkingMode.SHOW_ALL); + when(telegramUserService.getOrCreateUser(from)).thenReturn(user); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + command.languageCode("en"); + handler.handleInner(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SendMessage.class); + verify(telegramBot).execute(captor.capture()); + assertTrue(captor.getValue().getText().contains("Show reasoning")); + } + + @Test + void shouldShowCurrentModeInPromptWhenUserHasToolsOnly() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + Message message = mock(Message.class); + User from = mock(User.class); + when(update.getMessage()).thenReturn(message); + when(message.getFrom()).thenReturn(from); + + TelegramUser user = new TelegramUser(); + user.setThinkingMode(ThinkingMode.HIDE_REASONING); + when(telegramUserService.getOrCreateUser(from)).thenReturn(user); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + command.languageCode("en"); + handler.handleInner(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SendMessage.class); + verify(telegramBot).execute(captor.capture()); + assertTrue(captor.getValue().getText().contains("Tools only")); + } + + @Test + void shouldShowCurrentModeInPromptWhenUserHasSilent() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + Message message = mock(Message.class); + User from = mock(User.class); + when(update.getMessage()).thenReturn(message); + when(message.getFrom()).thenReturn(from); + + TelegramUser user = new TelegramUser(); + user.setThinkingMode(ThinkingMode.SILENT); + when(telegramUserService.getOrCreateUser(from)).thenReturn(user); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + command.languageCode("en"); + handler.handleInner(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SendMessage.class); + verify(telegramBot).execute(captor.capture()); + assertTrue(captor.getValue().getText().contains("Silent mode")); + } + + @Test + void shouldPersistShowAllWhenThinkingShowAllCallback() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + User from = mock(User.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("THINKING_SHOW_ALL"); + when(cq.getFrom()).thenReturn(from); + when(from.getId()).thenReturn(USER_ID); + when(cq.getId()).thenReturn("cq-1"); + Message callbackMessage = mock(Message.class); + when(callbackMessage.getMessageId()).thenReturn(77); + when(cq.getMessage()).thenReturn(callbackMessage); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + command.languageCode("en"); + + handler.handleInner(command); + + verify(telegramUserService).updateThinkingMode(eq(USER_ID), eq(ThinkingMode.SHOW_ALL)); + verify(telegramBot).execute(any(AnswerCallbackQuery.class)); + verify(telegramBot).execute(any(DeleteMessage.class)); + } + + @Test + void shouldPersistHideReasoningWhenThinkingHideReasoningCallback() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + User from = mock(User.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("THINKING_HIDE_REASONING"); + when(cq.getFrom()).thenReturn(from); + when(from.getId()).thenReturn(USER_ID); + when(cq.getId()).thenReturn("cq-2"); + Message callbackMessage = mock(Message.class); + when(callbackMessage.getMessageId()).thenReturn(88); + when(cq.getMessage()).thenReturn(callbackMessage); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + command.languageCode("en"); + + handler.handleInner(command); + + verify(telegramUserService).updateThinkingMode(eq(USER_ID), eq(ThinkingMode.HIDE_REASONING)); + verify(telegramBot).execute(any(AnswerCallbackQuery.class)); + verify(telegramBot).execute(any(DeleteMessage.class)); + } + + @Test + void shouldPersistSilentWhenThinkingSilentCallback() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + User from = mock(User.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("THINKING_SILENT"); + when(cq.getFrom()).thenReturn(from); + when(from.getId()).thenReturn(USER_ID); + when(cq.getId()).thenReturn("cq-3"); + Message callbackMessage = mock(Message.class); + when(callbackMessage.getMessageId()).thenReturn(89); + when(cq.getMessage()).thenReturn(callbackMessage); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + command.languageCode("en"); + + handler.handleInner(command); + + verify(telegramUserService).updateThinkingMode(eq(USER_ID), eq(ThinkingMode.SILENT)); + verify(telegramBot).execute(any(AnswerCallbackQuery.class)); + verify(telegramBot).execute(any(DeleteMessage.class)); + } + + @Test + void shouldDeleteMenuWhenThinkingCancelCallback() throws TelegramApiException { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + CallbackQuery cq = mock(CallbackQuery.class); + when(update.getCallbackQuery()).thenReturn(cq); + when(cq.getData()).thenReturn("THINKING_CANCEL"); + when(cq.getId()).thenReturn("cq-4"); + Message callbackMessage = mock(Message.class); + when(callbackMessage.getMessageId()).thenReturn(99); + when(cq.getMessage()).thenReturn(callbackMessage); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, new TelegramCommandType(TelegramCommand.THINKING), update); + + handler.handleInner(command); + + verify(telegramBot).execute(any(AnswerCallbackQuery.class)); + verify(telegramBot).execute(any(DeleteMessage.class)); + verify(telegramUserService, never()).updateThinkingMode(anyLong(), any(ThinkingMode.class)); + } + + @Test + void getSupportedCommandText_returnsLocalizedDesc() { + assertEquals("/thinking - configure reasoning visibility", handler.getSupportedCommandText("en")); + } +} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java index 2508bd08..054ef354 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java @@ -5,6 +5,7 @@ import io.github.ngirchev.opendaimon.common.agent.AgentRequest; import io.github.ngirchev.opendaimon.common.agent.AgentStreamEvent; import io.github.ngirchev.opendaimon.common.ai.ModelCapabilities; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; import io.github.ngirchev.opendaimon.common.ai.command.AICommand; import io.github.ngirchev.opendaimon.common.ai.pipeline.AIRequestPipeline; import io.github.ngirchev.opendaimon.common.service.AIGatewayRegistry; @@ -15,6 +16,7 @@ import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamRenderer; import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageService; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; @@ -476,6 +478,92 @@ void shouldSplitOversizedSingleParagraphWhenSendingFinalAnswer() { .allSatisfy(html -> assertThat(html.length()).isLessThanOrEqualTo(120)); } + @Test + @DisplayName("should preserve thinking line above tool-call block when mode is SHOW_ALL") + void shouldPreserveThinkingAboveToolCallWhenShowAll() { + MessageHandlerContext ctx = createContextWithMessage("Compare", Set.of(ModelCapabilities.WEB)); + // Per-user thinking mode = SHOW_ALL, set via /thinking command + TelegramUser userWithPreserve = new TelegramUser(); + userWithPreserve.setThinkingMode(ThinkingMode.SHOW_ALL); + ctx.setTelegramUser(userWithPreserve); + + when(messageSender.sendHtmlAndGetId(eq(CHAT_ID), anyString(), eq(USER_MSG_ID), eq(true))) + .thenReturn(STATUS_MSG_ID); + + Flux stream = Flux.just( + AgentStreamEvent.thinking(0), + AgentStreamEvent.toolCall("web_search", "{\"q\":\"London weather\"}", 0), + AgentStreamEvent.observation("rain", false, 0), + AgentStreamEvent.finalAnswer("It rains in London.", 0)); + when(agentExecutor.executeStream(any(AgentRequest.class))).thenReturn(stream); + + actions.generateResponse(ctx); + + String statusContent = ctx.getStatusBuffer().toString(); + // When thinking-preserve is ON, the reasoning content before the tool-call block + // must NOT be stripped — the tool block must be appended after it. + // Verify the tool block appears in the transcript. + assertThat(statusContent).contains("🔧 Tool:"); + assertThat(statusContent.indexOf("🔧 Tool:")) + .as("tool-call block must be present in status content") + .isGreaterThanOrEqualTo(0); + } + + @Test + @DisplayName("should overwrite thinking line with tool-call block when mode is HIDE_REASONING") + void shouldOverwriteThinkingWhenToolsOnly() { + // Per-user thinking mode = HIDE_REASONING (default) + MessageHandlerContext ctx = createContextWithMessage("Compare", Set.of(ModelCapabilities.WEB)); + TelegramUser userWithoutPreserve = new TelegramUser(); + userWithoutPreserve.setThinkingMode(ThinkingMode.HIDE_REASONING); + ctx.setTelegramUser(userWithoutPreserve); + + when(messageSender.sendHtmlAndGetId(eq(CHAT_ID), anyString(), eq(USER_MSG_ID), eq(true))) + .thenReturn(STATUS_MSG_ID); + + // Simulate reasoning arriving then tool call — the thinking content should be gone. + Flux stream = Flux.just( + AgentStreamEvent.thinking(0), + AgentStreamEvent.toolCall("web_search", "{\"q\":\"London weather\"}", 0), + AgentStreamEvent.observation("rain", false, 0), + AgentStreamEvent.finalAnswer("It rains in London.", 0)); + when(agentExecutor.executeStream(any(AgentRequest.class))).thenReturn(stream); + + actions.generateResponse(ctx); + + // Verify the tool-call block is present (current behaviour preserved). + assertThat(ctx.getStatusBuffer().toString()).contains("🔧 Tool:"); + } + + @Test + @DisplayName("should suppress thinking rendering in SILENT mode — no placeholder, renderer returns NoOp") + void shouldSuppressThinkingRenderingInSilentMode() { + MessageHandlerContext ctx = createContextWithMessage("Compare", Set.of(ModelCapabilities.WEB)); + TelegramUser silentUser = new TelegramUser(); + silentUser.setThinkingMode(ThinkingMode.SILENT); + ctx.setTelegramUser(silentUser); + + // In SILENT mode the status message is sent but should NOT contain the thinking placeholder. + when(messageSender.sendHtmlAndGetId(eq(CHAT_ID), anyString(), eq(USER_MSG_ID), eq(true))) + .thenReturn(STATUS_MSG_ID); + + Flux stream = Flux.just( + AgentStreamEvent.thinking(0), + AgentStreamEvent.toolCall("web_search", "{\"q\":\"London weather\"}", 0), + AgentStreamEvent.observation("rain", false, 0), + AgentStreamEvent.finalAnswer("It rains in London.", 0)); + when(agentExecutor.executeStream(any(AgentRequest.class))).thenReturn(stream); + + actions.generateResponse(ctx); + + // The status buffer must NOT contain the thinking placeholder. + assertThat(ctx.getStatusBuffer().toString()) + .as("SILENT mode must suppress the 💭 Thinking... placeholder") + .doesNotContain(STATUS_THINKING_LINE); + // Tool blocks and observations must still be present. + assertThat(ctx.getStatusBuffer().toString()).contains("🔧 Tool:"); + } + // ── Helpers ────────────────────────────────────────────────────────── private static final String STATUS_MAX_ITER_LINE = "⚠️ reached iteration limit"; From c4764bc000eb5e16149bf3573d8f4ee155693cd3 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Thu, 23 Apr 2026 22:10:59 +0300 Subject: [PATCH 06/14] Added mode thinking --- opendaimon-telegram/TELEGRAM_MODULE.md | 44 +++++--- .../fsm/TelegramMessageHandlerActions.java | 84 +++++++++++--- ...ramMessageHandlerActionsStreamingTest.java | 105 +++++++++++++++++- 3 files changed, 192 insertions(+), 41 deletions(-) diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index d79b6876..4ed67118 100644 --- a/opendaimon-telegram/TELEGRAM_MODULE.md +++ b/opendaimon-telegram/TELEGRAM_MODULE.md @@ -563,31 +563,39 @@ arrives the reasoning line is **overwritten** by the tool block. Final transcrip contains only tool blocks and observations — reasoning was part of the live stream but did not survive into the final message. -**🤫 Silent mode (`SILENT`)** — no thinking-related rendering ever. The -`💭 Thinking...` placeholder is never written, and `THINKING` stream events are -dropped at the renderer boundary (`TelegramAgentStreamRenderer.renderThinking()` -returns `NoOp()` for SILENT users). The status message starts accumulating content -only when the first `tool_call` arrives. Same final transcript as `Tools only`; the -difference is strictly in the streaming UX (no visible activity between tool calls). +**🤫 Silent mode (`SILENT`)** — complete silence during the agent loop. +**No status message is created at all** — `ensureStatusMessage()` returns +early for SILENT users without invoking `sendHtmlAndGetId`. Every +buffer-mutating `RenderedUpdate` case (`ReplaceTrailingThinkingLine`, +`AppendFreshThinking`, `AppendToolCall`, `AppendObservation`, +`AppendErrorToStatus`, `RollbackAndAppendToolCall`) is gated by +`isThinkingSilent(ctx)` and no-ops. `PARTIAL_ANSWER` events are suppressed +too, so the tentative-answer bubble never opens. When the agent reaches +`FINAL_ANSWER`, `generateAgentResponse()` takes the "no tentative bubble +opened" branch and sends a **fresh message** with the final answer text +via `sendTextByParagraphs`. The user sees: their own message → silence +while the agent works → final answer. Nothing in between. ##### Comparison across modes | Dimension | Show reasoning | Tools only | Silent | |---|---|---|---| | `💭 Thinking...` placeholder visible during stream | ✅ | ✅ | ❌ | -| Reasoning text visible during stream | ✅ (persists) | ✅ (briefly, then overwritten) | ❌ (never rendered) | +| Reasoning text visible during stream | ✅ (persists) | ✅ (briefly, then overwritten) | ❌ | | Reasoning text in final transcript | ✅ (above each tool block) | ❌ | ❌ | -| Tool blocks visible during stream | ✅ | ✅ | ✅ | -| Tool blocks in final transcript | ✅ | ✅ | ✅ | -| Observations in final transcript | ✅ | ✅ | ✅ | -| Final answer | ✅ | ✅ | ✅ | - -Key insight: `Tools only` and `Silent` produce **identical final transcripts** — -they differ only in whether the user sees any thinking-related activity during -the stream. `Tools only` gives "agent is working" feedback (thinking placeholder -pulses, reasoning flashes between tool calls). `Silent` removes that feedback -entirely. The choice is strictly a streaming-UX preference, not an information -tradeoff. +| Tool blocks visible during stream | ✅ | ✅ | ❌ | +| Tool blocks in final transcript | ✅ | ✅ | ❌ | +| Observations in final transcript | ✅ | ✅ | ❌ | +| Final answer | ✅ | ✅ | ✅ (fresh message) | + +Key insight: `Silent` is **radical silence** — it is not "Tools only minus +the thinking placeholder". `Tools only` still shows tool-call blocks and +observations in a running status message (a live log of agent work). +`Silent` suppresses the status message entirely and delivers only the +final answer. Tradeoff: `Tools only` keeps the user informed that the +agent is doing multi-step work; `Silent` hides all intermediate activity +and may appear non-responsive while long tool calls are running. The +choice is strictly a product-UX preference for visibility vs cleanliness. ### Final answer transition (tentative + rollback) diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java index 0f0893c9..59bfc30c 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java @@ -447,6 +447,12 @@ private Mono handleAgentStreamEvent(MessageHandlerContext ctx, AgentStream } if (event.type() == AgentStreamEvent.EventType.PARTIAL_ANSWER) { + // SILENT: suppress the tentative-answer bubble. PARTIAL_ANSWER chunks are + // dropped; the final answer is delivered as a fresh message via the + // "no tentative bubble opened" branch in generateAgentResponse. + if (isThinkingSilent(ctx)) { + return Mono.empty(); + } handlePartialAnswer(ctx, event); return Mono.empty(); } @@ -624,22 +630,51 @@ private static String collapseToSingleLine(String s) { private Mono applyUpdate(MessageHandlerContext ctx, RenderedUpdate update) { return switch (update) { - case RenderedUpdate.ReplaceTrailingThinkingLine r -> Mono.fromRunnable(() -> - replaceTrailingThinkingLineWithEscaped(ctx, - "" + collapseToSingleLine(TelegramHtmlEscaper.escape(r.reasoning())) + "", - /*forceFlush=*/ false)); - case RenderedUpdate.AppendFreshThinking ignored -> Mono.fromRunnable(() -> - appendToStatusBuffer(ctx, "\n\n" + STATUS_THINKING_LINE, /*forceFlush=*/ false)); - case RenderedUpdate.AppendToolCall tc -> - appendToolCallBlock(ctx, tc.toolName(), tc.args()); - case RenderedUpdate.AppendObservation obs -> - appendObservationMarker(ctx, obs.kind(), obs.errorSummary()); - case RenderedUpdate.AppendErrorToStatus err -> Mono.fromRunnable(() -> - appendToStatusBuffer(ctx, - "\n\n❌ Error: " + TelegramHtmlEscaper.escape(err.message()), - /*forceFlush=*/ true)); - case RenderedUpdate.RollbackAndAppendToolCall rb -> - rollbackAndAppendToolCall(ctx, rb.toolName(), rb.args(), rb.foldedProse()); + case RenderedUpdate.ReplaceTrailingThinkingLine r -> Mono.fromRunnable(() -> { + if (isThinkingSilent(ctx)) { + return; + } + String reasoningHtml = "" + + collapseToSingleLine(TelegramHtmlEscaper.escape(r.reasoning())) + + ""; + // Multi-iteration SHOW_ALL path: when the buffer's trailing content is + // NOT a thinking placeholder or a prior overlay (i.e. an + // observation `` or a `🔧 Tool:` block ended the previous + // iteration), a new iteration's reasoning must be APPENDED as a new + // paragraph rather than REPLACE the last paragraph — otherwise the + // previous iteration's tool block and observation get erased. + String current = ctx.getStatusBuffer().toString(); + boolean trailingIsOverlay = current.endsWith("") + || current.endsWith(STATUS_THINKING_LINE); + if (trailingIsOverlay) { + replaceTrailingThinkingLineWithEscaped(ctx, reasoningHtml, /*forceFlush=*/ false); + } else { + appendToStatusBuffer(ctx, "\n\n" + reasoningHtml, /*forceFlush=*/ false); + } + }); + case RenderedUpdate.AppendFreshThinking ignored -> Mono.fromRunnable(() -> { + if (isThinkingSilent(ctx)) { + return; + } + appendToStatusBuffer(ctx, "\n\n" + STATUS_THINKING_LINE, /*forceFlush=*/ false); + }); + case RenderedUpdate.AppendToolCall tc -> isThinkingSilent(ctx) + ? Mono.empty() + : appendToolCallBlock(ctx, tc.toolName(), tc.args()); + case RenderedUpdate.AppendObservation obs -> isThinkingSilent(ctx) + ? Mono.empty() + : appendObservationMarker(ctx, obs.kind(), obs.errorSummary()); + case RenderedUpdate.AppendErrorToStatus err -> Mono.fromRunnable(() -> { + if (isThinkingSilent(ctx)) { + return; + } + appendToStatusBuffer(ctx, + "\n\n❌ Error: " + TelegramHtmlEscaper.escape(err.message()), + /*forceFlush=*/ true); + }); + case RenderedUpdate.RollbackAndAppendToolCall rb -> isThinkingSilent(ctx) + ? Mono.empty() + : rollbackAndAppendToolCall(ctx, rb.toolName(), rb.args(), rb.foldedProse()); case RenderedUpdate.NoOp ignored -> Mono.empty(); }; } @@ -652,6 +687,11 @@ private Mono applyUpdate(MessageHandlerContext ctx, RenderedUpdate update) * subsequent edits just overwrite the whole buffer. If the send fails the buffer * still carries the text and later edit attempts short-circuit. */ + private boolean isThinkingSilent(MessageHandlerContext ctx) { + TelegramUser user = ctx.getTelegramUser(); + return user != null && user.getThinkingMode() == ThinkingMode.SILENT; + } + private void ensureStatusMessage(MessageHandlerContext ctx) { if (ctx.getStatusMessageId() != null) { return; @@ -663,9 +703,17 @@ private void ensureStatusMessage(MessageHandlerContext ctx) { user != null ? user.getTelegramId() : null, user != null ? user.getThinkingMode() : "null-user", silent); - if (!silent) { - ctx.getStatusBuffer().append(STATUS_THINKING_LINE); + // SILENT: do NOT create a status message at all. The user's intent is radical + // silence — no thinking placeholder, no tool blocks, no observations in a + // running log. The final answer is delivered as a fresh message through the + // "no tentative bubble opened" branch in generateAgentResponse. All applyUpdate + // cases that mutate the status buffer also no-op for SILENT users, so nothing + // ever tries to edit this non-existent status message. + if (silent) { + ctx.setCurrentIteration(0); + return; } + ctx.getStatusBuffer().append(STATUS_THINKING_LINE); // Seed iteration 0 so the first null-content THINKING event isn't treated as a // rollover — otherwise the renderer would duplicate the thinking line. A new // AppendFreshThinking still fires when iteration 1 starts. diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java index 054ef354..b3bfa9c6 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java @@ -55,6 +55,7 @@ * and existing rollback triggers (text-marker scan and TOOL_CALL event) still fire. */ @ExtendWith(MockitoExtension.class) +@org.mockito.junit.jupiter.MockitoSettings(strictness = org.mockito.quality.Strictness.LENIENT) class TelegramMessageHandlerActionsStreamingTest { private static final int MAX_ITERATIONS = 5; @@ -556,12 +557,106 @@ void shouldSuppressThinkingRenderingInSilentMode() { actions.generateResponse(ctx); - // The status buffer must NOT contain the thinking placeholder. + // Version B: SILENT suppresses EVERYTHING during the agent loop — no placeholder, + // no tool blocks, no observations. Status buffer stays empty; final answer is + // sent as a fresh message via the "no tentative bubble opened" branch. assertThat(ctx.getStatusBuffer().toString()) - .as("SILENT mode must suppress the 💭 Thinking... placeholder") - .doesNotContain(STATUS_THINKING_LINE); - // Tool blocks and observations must still be present. - assertThat(ctx.getStatusBuffer().toString()).contains("🔧 Tool:"); + .as("SILENT mode must produce an empty status buffer (no placeholder, no tool blocks)") + .isEmpty(); + } + + @Test + @DisplayName("SILENT: should suppress placeholder across iteration boundaries (AppendFreshThinking path)") + void shouldSuppressThinkingAcrossIterationsInSilentMode() { + MessageHandlerContext ctx = createContextWithMessage("Compare", Set.of(ModelCapabilities.WEB)); + TelegramUser silentUser = new TelegramUser(); + silentUser.setThinkingMode(ThinkingMode.SILENT); + ctx.setTelegramUser(silentUser); + + when(messageSender.sendHtmlAndGetId(eq(CHAT_ID), anyString(), eq(USER_MSG_ID), eq(true))) + .thenReturn(STATUS_MSG_ID); + + // Simulate 2 iterations. iteration=1 crosses boundary → renderThinking would return + // AppendFreshThinking without SILENT guard. Defense-in-depth guard in applyUpdate + // must also drop it. + Flux stream = Flux.just( + AgentStreamEvent.thinking(0), + AgentStreamEvent.toolCall("web_search", "{\"q\":\"London\"}", 0), + AgentStreamEvent.observation("rain", false, 0), + AgentStreamEvent.thinking(1), // ← iteration boundary + AgentStreamEvent.toolCall("web_search", "{\"q\":\"Manchester\"}", 1), + AgentStreamEvent.observation("rain", false, 1), + AgentStreamEvent.finalAnswer("Rains everywhere.", 1)); + when(agentExecutor.executeStream(any(AgentRequest.class))).thenReturn(stream); + + actions.generateResponse(ctx); + + // Version B: SILENT across 2+ iterations still produces an empty buffer — + // no iteration-boundary placeholders, no tool blocks, no observations. + assertThat(ctx.getStatusBuffer().toString()) + .as("SILENT mode must suppress ALL status rendering across iteration boundaries") + .isEmpty(); + } + + @Test + @DisplayName("SILENT: should suppress reasoning text overlay (ReplaceTrailingThinkingLine path)") + void shouldSuppressReasoningOverlayInSilentMode() { + MessageHandlerContext ctx = createContextWithMessage("Compare", Set.of(ModelCapabilities.WEB)); + TelegramUser silentUser = new TelegramUser(); + silentUser.setThinkingMode(ThinkingMode.SILENT); + ctx.setTelegramUser(silentUser); + + when(messageSender.sendHtmlAndGetId(eq(CHAT_ID), anyString(), eq(USER_MSG_ID), eq(true))) + .thenReturn(STATUS_MSG_ID); + + // thinking event WITH content (reasoning text) would normally produce + // ReplaceTrailingThinkingLine. For SILENT, buffer must stay clean of that content. + Flux stream = Flux.just( + AgentStreamEvent.thinking("I need to check the weather first.", 0), + AgentStreamEvent.toolCall("web_search", "{\"q\":\"London\"}", 0), + AgentStreamEvent.observation("rain", false, 0), + AgentStreamEvent.finalAnswer("Rain.", 0)); + when(agentExecutor.executeStream(any(AgentRequest.class))).thenReturn(stream); + + actions.generateResponse(ctx); + + // Version B: SILENT mode buffer stays empty even when reasoning text events arrive. + assertThat(ctx.getStatusBuffer().toString()) + .as("SILENT mode must not render reasoning text (or anything) into the buffer") + .isEmpty(); + } + + @Test + @DisplayName("SHOW_ALL: reasoning text must survive across iterations (reasoning visible before each tool block)") + void shouldPreserveReasoningAcrossIterationsInShowAllMode() { + MessageHandlerContext ctx = createContextWithMessage("Compare", Set.of(ModelCapabilities.WEB)); + TelegramUser user = new TelegramUser(); + user.setThinkingMode(ThinkingMode.SHOW_ALL); + ctx.setTelegramUser(user); + + when(messageSender.sendHtmlAndGetId(eq(CHAT_ID), anyString(), eq(USER_MSG_ID), eq(true))) + .thenReturn(STATUS_MSG_ID); + + Flux stream = Flux.just( + AgentStreamEvent.thinking("First I check London.", 0), + AgentStreamEvent.toolCall("web_search", "{\"q\":\"London\"}", 0), + AgentStreamEvent.observation("rain", false, 0), + AgentStreamEvent.thinking("Now Manchester.", 1), + AgentStreamEvent.toolCall("web_search", "{\"q\":\"Manchester\"}", 1), + AgentStreamEvent.observation("rain", false, 1), + AgentStreamEvent.finalAnswer("Rains everywhere.", 1)); + when(agentExecutor.executeStream(any(AgentRequest.class))).thenReturn(stream); + + actions.generateResponse(ctx); + + String content = ctx.getStatusBuffer().toString(); + assertThat(content) + .as("SHOW_ALL must retain both reasoning snippets in the final buffer") + .contains("First I check London") + .contains("Now Manchester"); + // Both tool blocks must be present + long toolBlockCount = content.lines().filter(l -> l.contains("🔧 Tool:")).count(); + assertThat(toolBlockCount).as("two tool-call blocks expected").isEqualTo(2); } // ── Helpers ────────────────────────────────────────────────────────── From c36aedef2784f9dba690bb7f9c9dfadf0730b541 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Thu, 23 Apr 2026 22:38:41 +0300 Subject: [PATCH 07/14] Refactoring docs --- docs/{ => plan}/agent-evolution-roadmap.md | 0 docs/{ => plan}/agent-framework-plan.md | 4 +- .../agent-telegram-integration-plan.md | 0 .../chat-streaming-disable-toggle-plan.md} | 0 docs/provider-registry-plan.md | 231 ------------------ docs/refactor-rag-storage.md | 77 ------ docs/refactor-vision-capability-detection.md | 127 ---------- docs/rename-manual-test-profile.md | 78 ------ docs/telegram-chat-scoped-history.md | 40 --- docs/telegram-thinking-modes.md | 2 +- docs/telegram-two-update-coalescing-plan.md | 31 --- .../telegrambots/meta/api/objects/User.java | 100 -------- 12 files changed, 3 insertions(+), 687 deletions(-) rename docs/{ => plan}/agent-evolution-roadmap.md (100%) rename docs/{ => plan}/agent-framework-plan.md (96%) rename docs/{ => plan}/agent-telegram-integration-plan.md (100%) rename docs/{chat-streaming-disable-toggle.md => plan/chat-streaming-disable-toggle-plan.md} (100%) delete mode 100644 docs/provider-registry-plan.md delete mode 100644 docs/refactor-rag-storage.md delete mode 100644 docs/refactor-vision-capability-detection.md delete mode 100644 docs/rename-manual-test-profile.md delete mode 100644 docs/telegram-chat-scoped-history.md delete mode 100644 docs/telegram-two-update-coalescing-plan.md delete mode 100644 org/telegram/telegrambots/meta/api/objects/User.java diff --git a/docs/agent-evolution-roadmap.md b/docs/plan/agent-evolution-roadmap.md similarity index 100% rename from docs/agent-evolution-roadmap.md rename to docs/plan/agent-evolution-roadmap.md diff --git a/docs/agent-framework-plan.md b/docs/plan/agent-framework-plan.md similarity index 96% rename from docs/agent-framework-plan.md rename to docs/plan/agent-framework-plan.md index e0aae48b..ab6ce139 100644 --- a/docs/agent-framework-plan.md +++ b/docs/plan/agent-framework-plan.md @@ -1,9 +1,9 @@ # Agent Framework Pivot — Implementation Plan -> **Architecture documentation**: see [`opendaimon-common/.../agent/README.md`](../opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/README.md) +> **Architecture documentation**: see [`opendaimon-common/.../agent/README.md`](../../opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/README.md) > for the full architecture guide with diagrams, sequence flows, and bean wiring. > -> **Sequence diagram**: see [`docs/agent-sequence.puml`](agent-sequence.puml) +> **Sequence diagram**: see [`docs/agent-sequence.puml`](../agent-sequence.puml) ## Overview diff --git a/docs/agent-telegram-integration-plan.md b/docs/plan/agent-telegram-integration-plan.md similarity index 100% rename from docs/agent-telegram-integration-plan.md rename to docs/plan/agent-telegram-integration-plan.md diff --git a/docs/chat-streaming-disable-toggle.md b/docs/plan/chat-streaming-disable-toggle-plan.md similarity index 100% rename from docs/chat-streaming-disable-toggle.md rename to docs/plan/chat-streaming-disable-toggle-plan.md diff --git a/docs/provider-registry-plan.md b/docs/provider-registry-plan.md deleted file mode 100644 index fb22ef63..00000000 --- a/docs/provider-registry-plan.md +++ /dev/null @@ -1,231 +0,0 @@ -# Provider Registry: String-based provider-type - -## Problem - -`ProviderType` enum (`OLLAMA`, `OPENAI`) hardcoded in `SpringAIModelConfig.java`. -Adding a new AI provider (Anthropic, Mistral, Vertex) requires changes in 5-6 files -with scattered switch/if statements. - -**Current dispatch points (5 files):** -1. `SpringAIPromptFactory.java` — getChatClient(), buildChatOptions(), isOpenAIProvider() -2. `DelegatingAgentChatModel.java` — selectBean(), enrichWithModelOptions(), enrichForOllama() -3. `DelegatingEmbeddingModel.java` — createEmbeddingModel() switch -4. `SpringAIModelType.java` — isOpenAIModel(), isOllamaModel() -5. `SpringAIModelRegistry.java` — ProviderType.OPENAI checks for OpenRouter models - -## Solution - -Replace enum with String + Strategy Registry pattern: -- `provider-type` in YAML becomes a free-form String (case-insensitive) -- Each provider is a self-contained adapter class implementing `ChatModelProviderAdapter` -- Adapters auto-register in `ChatModelProviderRegistry` -- Dispatch files delegate to registry instead of doing provider-specific branching - -**Goal:** `provider-type: "anthropic"` in YAML works by adding 1 new adapter class + 1 @Bean. -Zero changes to existing dispatch files. - -## Design - -### New interface: `ChatModelProviderAdapter` - -```java -package io.github.ngirchev.opendaimon.ai.springai.config; - -/** - * SPI for AI model providers. Each implementation encapsulates all provider-specific - * logic: bean selection, ChatOptions building, prompt enrichment, embedding model creation. - * - * Implementations are registered in {@link ChatModelProviderRegistry} and looked up - * by the {@code provider-type} string from YAML model configuration. - */ -public interface ChatModelProviderAdapter { - - /** - * Provider key matching {@code provider-type} in YAML. - * Compared case-insensitively. E.g. "ollama", "openai". - */ - String providerKey(); - - /** - * Returns the ChatModel bean for this provider. - * Used by DelegatingAgentChatModel.selectBean(). - */ - ChatModel getChatModel(); - - /** - * Returns a cached ChatClient wrapping the ChatModel. - * Used by SpringAIPromptFactory.getChatClient(). - */ - ChatClient getChatClient(); - - /** - * Builds provider-specific ChatOptions for the prompt factory. - * E.g. OllamaChatOptions (think, numPredict) vs OpenAiChatOptions (extraBody, reasoning). - * - * Used by SpringAIPromptFactory.buildChatOptions(). - */ - ChatOptions buildChatOptions(SpringAIModelConfig modelConfig, String modelName, - Map body, OpenDaimonChatOptions chatOptions); - - /** - * Enriches an agent Prompt with provider-specific options - * (model name, think mode, tool callbacks). - * - * Used by DelegatingAgentChatModel.enrichWithModelOptions(). - */ - Prompt enrichAgentPrompt(Prompt prompt, SpringAIModelConfig modelConfig); - - /** - * Creates provider-specific EmbeddingModel for the given model config. - * Returns null if this provider does not support embeddings. - * - * Used by DelegatingEmbeddingModel.createEmbeddingModel(). - */ - EmbeddingModel createEmbeddingModel(SpringAIModelConfig modelConfig); -} -``` - -### Registry: `ChatModelProviderRegistry` - -```java -package io.github.ngirchev.opendaimon.ai.springai.config; - -/** - * Registry of available AI model provider adapters. - * Populated at startup from all ChatModelProviderAdapter beans in the context. - * Lookup is case-insensitive by provider key. - */ -public class ChatModelProviderRegistry { - - private final Map adapters; // key = lowercase providerKey - - public ChatModelProviderRegistry(List adapters) { - this.adapters = adapters.stream() - .collect(Collectors.toMap( - a -> a.providerKey().toLowerCase(), - Function.identity())); - } - - public ChatModelProviderAdapter getAdapter(String providerType) { - var adapter = adapters.get(providerType.toLowerCase()); - if (adapter == null) { - throw new IllegalStateException( - "Unknown provider-type: '" + providerType + "'. Available: " + adapters.keySet()); - } - return adapter; - } - - /** Returns the first available adapter (for default options). */ - public Optional getFirstAvailable() { - return adapters.values().stream().findFirst(); - } -} -``` - -### Two implementations - -**`OllamaChatModelProviderAdapter`** — encapsulates all Ollama-specific logic: -- `getChatModel()` -> `ObjectProvider` -- `getChatClient()` -> lazy-cached `ChatClient.builder(ollamaChatModel).build()` -- `buildChatOptions()` -> `OllamaChatOptions` with think, numPredict, computeOllamaNumPredict() -- `enrichAgentPrompt()` -> enrichForOllama() logic from DelegatingAgentChatModel -- `createEmbeddingModel()` -> `OllamaEmbeddingModel` via `OllamaApi` - -**`OpenAiChatModelProviderAdapter`** — encapsulates all OpenAI/OpenRouter-specific logic: -- `getChatModel()` -> `ObjectProvider` -- `getChatClient()` -> lazy-cached `ChatClient.builder(openAiChatModel).build()` -- `buildChatOptions()` -> `OpenAiChatOptions` with extraBody, maxPrice, reasoning -- `enrichAgentPrompt()` -> `ToolCallingChatOptions` logic -- `createEmbeddingModel()` -> `OpenAiEmbeddingModel` via `OpenAiApi` - -## File Changes - -### NEW Files (4) - -| File | Package | Description | -|------|---------|-------------| -| `ChatModelProviderAdapter.java` | `.config` | Interface (SPI) | -| `ChatModelProviderRegistry.java` | `.config` | Registry (Map lookup) | -| `OllamaChatModelProviderAdapter.java` | `.provider` | Ollama impl | -| `OpenAiChatModelProviderAdapter.java` | `.provider` | OpenAI/OpenRouter impl | - -All in `opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/` - -### MODIFY Files (7 production) - -| File | What changes | -|------|-------------| -| `SpringAIModelConfig.java` | `enum ProviderType` -> `private String providerType` | -| `SpringAIPromptFactory.java` | Remove ObjectProviders, cached clients, provider-specific methods. Inject `ChatModelProviderRegistry`. Delegate to `registry.getAdapter(...)` | -| `DelegatingAgentChatModel.java` | Remove ChatModel fields, selectBean(), enrichForOllama(). Inject `ChatModelProviderRegistry`. Delegate to adapter | -| `DelegatingEmbeddingModel.java` | Remove API ObjectProviders, createEmbeddingModel() switch. Inject `ChatModelProviderRegistry`. Delegate to adapter | -| `SpringAIAutoConfig.java` | New @Bean for registry + 2 adapters. Update springAIPromptFactory() wiring | -| `AgentAutoConfig.java` | Update delegatingAgentChatModel() — inject registry instead of ObjectProviders | -| `RAGAutoConfig.java` | Update simpleVectorStore() — inject registry instead of API ObjectProviders | - -### ADAPT Files (4 informational — minor String changes) - -| File | What changes | -|------|-------------| -| `SpringAIModelRegistry.java` | `ProviderType.OPENAI` -> `"openai"` String constant | -| `ModelListAIGateway.java` | `providerLabel()` — capitalize string or registry lookup | -| `SpringAIGateway.java` | Null check on String instead of enum | -| `SpringAIModelType.java` | `isOpenAIModel()` / `isOllamaModel()` — String comparison | - -### Test Updates (~8 files) - -| File | What changes | -|------|-------------| -| `DelegatingAgentChatModelTest` | Mock ChatModelProviderRegistry | -| `DelegatingEmbeddingModelTest` | Mock ChatModelProviderRegistry | -| `SpringAIPromptFactoryTest` | Mock registry | -| `ProviderConfigIT` | Update wiring | -| `RAGAutoConfigIT` | String instead of enum | -| `ImagePdfVisionCacheFixtureIT` | String instead of enum | -| NEW: `OllamaChatModelProviderAdapterTest` | Unit test for Ollama adapter | -| NEW: `OpenAiChatModelProviderAdapterTest` | Unit test for OpenAI adapter | - -## YAML Compatibility - -Before: -```yaml -provider-type: OLLAMA # enum, case-sensitive -provider-type: OPENAI -``` - -After: -```yaml -provider-type: ollama # String, case-insensitive -provider-type: OLLAMA # still works (toLowerCase in registry) -provider-type: openai -provider-type: anthropic # just add an adapter class -``` - -## Adding a 3rd Provider (e.g., Anthropic) - -Steps: -1. `pom.xml` -> add `spring-ai-starter-model-anthropic` dependency -2. `application.yml` -> `spring.ai.anthropic.api-key: ${ANTHROPIC_KEY}` -3. **NEW:** `AnthropicChatModelProviderAdapter.java` (1 class, ~100 lines) -4. **`SpringAIAutoConfig.java`** -> add `@Bean` for `AnthropicChatModelProviderAdapter` -5. YAML models list: `provider-type: anthropic` - -**Result: 1 new adapter class + 1 @Bean definition + config. Zero changes to dispatch files.** - -## Verification - -1. `./mvnw clean compile -pl opendaimon-spring-ai` — compilation -2. `./mvnw clean test -pl opendaimon-spring-ai` — unit tests (adapters + dispatch) -3. `./mvnw clean verify -pl opendaimon-app -am` — IT + fixture tests -4. Check logs: no "Unknown provider-type" errors -5. Verify YAML `provider-type: OLLAMA` (uppercase) still works -6. Manual IT with Ollama (if available) — qwen3.5:4b routes to Ollama client -7. Check `SpringAIModelRegistry` log snapshot — providers shown correctly - -## Key Design Decisions - -- **String over Enum:** Open-Closed Principle. Adding a provider should not modify existing code. - Trade-off: lose compile-time exhaustive switch, gain runtime flexibility with fast-fail at startup. -- **Interface (SPI) over God Object:** Each adapter owns ALL provider-specific logic (bean, options, enrichment, embedding). No scatter across files. -- **Lazy ChatClient caching in adapters:** Same volatile + double-checked locking pattern currently in SpringAIPromptFactory. Moved into each adapter. -- **Helper methods move to adapters:** `resolveReasoningTokenBudget()`, `computeOllamaNumPredict()`, `extractExtraBody()`, `normalizeMaxPrice()` move from SpringAIPromptFactory into respective adapters. diff --git a/docs/refactor-rag-storage.md b/docs/refactor-rag-storage.md deleted file mode 100644 index d068031a..00000000 --- a/docs/refactor-rag-storage.md +++ /dev/null @@ -1,77 +0,0 @@ -# Refactor: Move RAG documentId from thread memoryBullets to message metadata - -## Problem - -RAG documentId is stored in `ConversationThread.memoryBullets` as a custom-format string: -``` -[RAG:documentId:abc123:filename:report.pdf] -``` - -This is wrong because: -- The file is attached to a **message**, not to the thread -- memoryBullets has its own purpose (conversation memory/summary) -- Custom string format requires manual parsing (`extractRagDocumentIds`) -- Stale thread objects can overwrite memoryBullets (the bug we hit with scoped threads in v13) - -## Target - -Store RAG documentId in `OpenDaimonMessage.metadata` (jsonb) on the USER message that had the attachment: -```json -{ - "ragDocumentId": "abc123", - "ragFilename": "report.pdf" -} -``` - -## Changes - -### 1. SpringAIGateway — first message (store) - -**Current**: `storeDocumentIdsInThread()` → finds thread by threadKey → writes to `thread.memoryBullets` → `threadRepo.save(thread)` - -**New**: Return processed documentIds from `processRagIfEnabled()` up to the handler. Handler saves them into `OpenDaimonMessage.metadata` of the USER message that was just created. - -Alternative: gateway writes directly to message metadata if it has access to the message ID via `AICommand.metadata`. - -### 2. SpringAIGateway — follow-up (read) - -**Current**: `processFollowUpRagIfAvailable()` → finds thread → `extractRagDocumentIds(thread.getMemoryBullets())` → fetches chunks - -**New**: Query messages by thread where metadata contains `ragDocumentId` → collect documentIds → fetch chunks. - -Option A — gateway queries messages directly: -```java -List messagesWithRag = messageRepository - .findByThreadAndMetadataContaining(thread, "ragDocumentId"); -``` - -Option B — handler resolves documentIds from message history and passes them via `AICommand.metadata`: -```java -metadata.put("ragDocumentIds", "abc123,def456"); -``` - -Option B keeps gateway decoupled from message repository. - -### 3. Remove from memoryBullets - -- Delete `storeDocumentIdsInThread()` method -- Delete `extractRagDocumentIds()` method -- Delete `RAG_BULLET_PREFIX`, `RAG_BULLET_FILENAME_SEPARATOR` constants -- Remove RAG-related entries from memoryBullets in existing threads (migration or lazy cleanup) - -### 4. Update tests - -- `SpringAIGatewayDocumentRagTest` — assertions checking memoryBullets → check message metadata -- `ImagePdfVisionRagOllamaManualIT` — assertions checking `thread.getMemoryBullets()` → check user message metadata -- Fixture tests if affected - -### 5. Migration - -Optional: DB migration to move existing RAG entries from `conversation_thread.memory_bullets` to corresponding `open_daimon_message.metadata`. Or just let old threads lose RAG context (acceptable for local Ollama use case). - -## Benefits - -- documentId is where the file is — on the message -- No more stale thread overwrite bug (the root cause of the v13 regression) -- No custom string format parsing -- memoryBullets is free for its intended use diff --git a/docs/refactor-vision-capability-detection.md b/docs/refactor-vision-capability-detection.md deleted file mode 100644 index 95de915a..00000000 --- a/docs/refactor-vision-capability-detection.md +++ /dev/null @@ -1,127 +0,0 @@ -# Refactoring: Move Vision Capability Detection Before Gateway - -## Status: Completed - -This refactoring has been fully implemented. The sections below reflect the final state of the implementation, including components added beyond the original plan. - ---- - -## Problem Statement - -`ImagePdfVisionRagOllamaManualIT` revealed a critical architecture gap: - -1. REGULAR user sends a **CHAT** request with a PDF attachment -2. `DefaultAICommandFactory` created `ChatAICommand` with `CHAT` capability only (PDF is not IMAGE → no VISION added) -3. `SpringAIGateway` selected a CHAT-only model -4. **Inside the gateway** (`processOneDocumentForRag`), PDF text extraction failed → `DocumentContentNotExtractableException` -5. Gateway **internally** rendered PDF to images and called a VISION model for OCR (`extractTextFromImagesViaVision`) -6. **Result**: REGULAR user got VISION functionality that should have been blocked by priority routing - -**Root cause**: The decision "this document requires VISION" happened too late — deep inside `SpringAIGateway`, after model selection had already occurred. The priority/capability check in `DefaultAICommandFactory` never got a chance to block it. - -**Secondary problem**: `SpringAIGateway` (1167 lines) had accumulated too much branching logic — document analysis, PDF rendering, vision OCR, RAG indexing — turning a "gateway" into an orchestrator. This violated SRP and made the code hard to test and reason about. - ---- - -## Implemented Architecture - -``` -BEFORE: - IChatCommand → DefaultAICommandFactory → ChatAICommand(CHAT) → SpringAIGateway - ↓ - processOneDocumentForRag() - ↓ - PDF has no text? → render to images → VISION OCR ← BUG: bypasses priority - -AFTER: - IChatCommand - ↓ - AIRequestPipeline.prepareCommand() - ├── SpringDocumentOrchestrator.orchestrate() - │ ├── IDocumentContentAnalyzer → PdfTextDetector → IMAGE_ONLY or TEXT_EXTRACTABLE - │ ├── SpringDocumentPreprocessor.preprocess() → renders images, runs OCR, indexes RAG - │ └── stores documentIds in command metadata; builds augmented query - │ - ▼ - DefaultAICommandFactory.createCommand() - │ Sees IMAGE attachments (from PDF rendering if OCR failed) → adds VISION capability - │ priority check: REGULAR cannot use VISION → UnsupportedModelCapabilityException - │ (or VIP/ADMIN: VISION in required capabilities → VISION model selected) - ▼ - OrchestratedChatCommand(augmentedUserText, preprocessedAttachments) - ↓ - SpringAIGateway — thin executor: model selection + chat call only -``` - ---- - -## Implemented Components - -### New interfaces in `opendaimon-common` - -| Interface | Package | Role | -|-----------|---------|------| -| `AIRequestPipeline` | `common.ai.pipeline` | Entry point for handlers; wraps orchestrate → factory | -| `IDocumentOrchestrator` | `common.ai.document` | Coordinates document preprocessing + RAG + follow-up RAG | -| `IDocumentPreprocessor` | `common.ai.document` | ETL preprocessing (OCR, RAG indexing) before gateway call | -| `IDocumentContentAnalyzer` | `common.ai.document` | Analyzes document → determines required capabilities | -| `OrchestratedChatCommand` | `common.ai.command` | Wrapper command substituting userText and attachments after orchestration | -| `DocumentAnalysisResult` | `common.ai.document` | Analysis output: content type + required capabilities | -| `DocumentContentType` | `common.ai.document` | `TEXT_EXTRACTABLE`, `IMAGE_ONLY`, `UNSUPPORTED` | -| `DocumentPreprocessingResult` | `common.ai.document` | Preprocessing output: documentId, chunks, image attachments | - -### New implementations in `opendaimon-spring-ai` - -| Class | Role | -|-------|------| -| `SpringDocumentOrchestrator` | Orchestrates preprocessing + RAG; extracted from `SpringAIGateway` | -| `SpringDocumentPreprocessor` | PDF rendering, vision OCR, Tika text extraction, RAG indexing; extracted from `SpringAIGateway` | -| `SpringDocumentContentAnalyzer` | MIME/extension type detection; extracted from `SpringAIGateway.extractDocumentType()` | -| `PdfTextDetector` | Lightweight PDFBox text presence check; no chunking/embedding | - ---- - -## What Moved Where - -| Original location in `SpringAIGateway` | Moved to | -|----------------------------------------|----------| -| `processRagIfEnabled()` | `SpringDocumentOrchestrator.orchestrate()` | -| `processFollowUpRagIfAvailable()` | `SpringDocumentOrchestrator.processFollowUpRagIfAvailable()` | -| `buildRagAugmentedQuery()` | `SpringDocumentOrchestrator` | -| `storeDocumentIdsInCommandMetadata()` | `SpringDocumentOrchestrator` | -| `processOneDocumentForRag()` | `SpringDocumentPreprocessor.preprocess()` | -| `renderPdfToImageAttachments()` | `SpringDocumentPreprocessor` | -| `extractTextFromImagesViaVision()` | `SpringDocumentPreprocessor` | -| `preprocessPdfPageForVisionOcr()`, `autoContrastGray()` | `SpringDocumentPreprocessor` | -| `stripModelInternalTokens()`, `isLikelyCompleteVisionExtraction()` | `SpringDocumentPreprocessor` | -| `extractDocumentType()`, `DOCUMENT_TYPE_MAPPINGS`, `DocumentTypeMapping` | `SpringDocumentContentAnalyzer` | - ---- - -## Key Behavioral Changes - -1. **Document orchestration happens before factory** — `AIRequestPipeline.prepareCommand()` runs `SpringDocumentOrchestrator` first, then delegates to `DefaultAICommandFactory`. The factory sees already-preprocessed attachments. - -2. **VISION detection fixed** — `DefaultAICommandFactory` adds VISION capability when it sees IMAGE attachments (either from the original request or from PDF rendering if OCR fallback left images). Priority enforcement now works end-to-end: REGULAR users are blocked before model selection. - -3. **Factory receives preprocessed state** — if PDF rendering succeeded but OCR failed, the factory sees IMAGE attachments and adds VISION. The gateway then selects a VISION-capable model to send the images directly. - -4. **Follow-up RAG stays in orchestrator** — `SpringDocumentOrchestrator.processFollowUpRagIfAvailable()` handles follow-up queries, not the gateway. - -5. **SpringAIGateway is thin** (~500 lines, was 1167): - - Model selection (capabilities + priority) - - Message building (system + user + media) - - Chat execution (stream/call) - - No document processing, no RAG logic - ---- - -## Risks and Mitigations - -| Risk | Severity | Mitigation | -|------|----------|------------| -| PDF analysis adds latency to every PDF request | MEDIUM | `PdfTextDetector` is lightweight — only reads first few pages, no embedding | -| Breaking existing fixture tests | HIGH | `./mvnw clean verify -pl opendaimon-app -am -Pfixture` run after each phase | -| `IDocumentContentAnalyzer` unavailable when RAG disabled | LOW | Pipeline skips orchestration when RAG is disabled; factory falls back to image-only detection | -| Circular dependency: factory → analyzer → DocumentProcessingService | MEDIUM | `PdfTextDetector` is standalone (PDFBox only), no spring-ai dependencies | -| Vision OCR needs model registry access | LOW | `SpringDocumentPreprocessor` injects `SpringAIModelRegistry` directly | diff --git a/docs/rename-manual-test-profile.md b/docs/rename-manual-test-profile.md deleted file mode 100644 index bba49c96..00000000 --- a/docs/rename-manual-test-profile.md +++ /dev/null @@ -1,78 +0,0 @@ -# Rename manual test profile - -## What changed - -The YAML profile for manual integration tests was renamed: - -- **Before**: `application-manual-ollama-e2e.yaml` (profile name: `manual-ollama-e2e`) -- **After**: `application-manual.yaml` (profile name: `manual`) - -The profile is shared by all manual tests (PDF/RAG, web tool calling, etc.). - -## Files to update - -### 1. YAML profile - -Rename: `opendaimon-app/src/it/resources/application-manual-ollama-e2e.yaml` -> `application-manual.yaml` - -Changes inside the YAML: -- Added `TOOL_CALLING`, `WEB`, `SUMMARIZATION` capabilities to `qwen3.5:4b` model (matching prod config) -- Added debug logging for `SpringAIPromptFactory`, `SpringAIChatService`, `WebTools`, `WebClientLogCustomizer` - -### 2. ImagePdfVisionRagOllamaManualIT - -`opendaimon-app/src/it/java/.../it/manual/ImagePdfVisionRagOllamaManualIT.java` - -Change `@ActiveProfiles`: -```java -// Before: -@ActiveProfiles({"integration-test", "manual-ollama-e2e"}) - -// After: -@ActiveProfiles({"integration-test", "manual"}) -``` - -No other changes needed in this test. - -### 3. WebToolCallingOllamaManualIT (new test) - -`opendaimon-app/src/it/java/.../it/manual/WebToolCallingOllamaManualIT.java` - -Change `@ActiveProfiles`: -```java -// Current (needs update): -@ActiveProfiles({"integration-test", "manual-ollama"}) - -// After: -@ActiveProfiles({"integration-test", "manual"}) -``` - -### 4. Delete old file - -Delete `opendaimon-app/src/it/resources/application-manual-ollama-e2e.yaml` if it still exists. -Also delete stale `opendaimon-app/target/test-classes/application-manual-ollama-e2e.yaml` (cleaned by `mvn clean`). - -## New test: WebToolCallingOllamaManualIT - -`opendaimon-app/src/it/java/.../it/manual/WebToolCallingOllamaManualIT.java` - -- Uses `@ActiveProfiles({"integration-test", "manual"})` (same shared profile) -- Uses `MockWebServer` to stub HTTP responses for `WebTools` (no `@MockitoBean` on `WebTools` - see note below) -- Verifies that `qwen3.5:4b` invokes Spring AI tool calling when message contains a URL - -Run command: -```bash -./mvnw -pl opendaimon-app -am clean test-compile failsafe:integration-test failsafe:verify \ - -Dit.test=WebToolCallingOllamaManualIT \ - -Dfailsafe.failIfNoSpecifiedTests=false \ - -Dmanual.ollama.e2e=true \ - -Dmanual.ollama.chat-model=qwen3.5:4b -``` - -## Important: do NOT use @MockitoBean on WebTools - -`@MockitoBean` creates a ByteBuddy proxy that loses `@Tool` annotations on methods. -Spring AI's `ChatClient.tools(object)` scans for `@Tool` via reflection and finds nothing on the mock. -Result: tools are silently not registered, model never calls them. - -Use `MockWebServer` or a real `WebTools` instance with stubbed HTTP layer instead. diff --git a/docs/telegram-chat-scoped-history.md b/docs/telegram-chat-scoped-history.md deleted file mode 100644 index 51249823..00000000 --- a/docs/telegram-chat-scoped-history.md +++ /dev/null @@ -1,40 +0,0 @@ -# Telegram Chat-Scoped History and Inline UX Plan - -## Summary -This plan defines a single business logic for Telegram bot interactions, with transport-specific handling: -- `message` channel (mentions/replies/commands) uses shared history scoped to `chat/group`. -- `inline_query` is not used as a dialog channel and returns an explicit instruction to use mention/reply instead. -- Progress is tracked via checklist items so work can continue across AI sessions. - -## Final Decisions -- History scope for Telegram dialog is `chat.id` (group/private chat), not user id. -- Group trigger policy is `mention/reply/command only`. -- Inline is intentionally non-dialog and should return a clear user guidance message. -- Group thread control (`/history`, `/threads`, `/newthread`) is allowed for any member who passes access control. - -## Progress Checklist -- [x] Add thread scope fields to `conversation_thread` (`scope_kind`, `scope_id`) with indexes. -- [x] Add Flyway migration for scope fields and backfill legacy rows with `scope_kind=USER`. -- [x] Extend thread selection service to resolve active thread by `(scope_kind, scope_id)`. -- [x] Update Telegram message flow to map all dialog requests to `TELEGRAM_CHAT` scope using `message.chat.id`. -- [x] Keep non-Telegram channels on existing user-scoped behavior. -- [x] Add group filter: process only mention/reply/command, ignore other group messages. -- [x] Add mention normalization: remove self-mention `@` before AI call. -- [x] Add fallback when normalized message is empty (no AI call). -- [x] Add dedicated inline handler that always returns guidance via `AnswerInlineQuery`. -- [x] Add i18n keys for inline-disabled guidance in `telegram_en.properties` and `telegram_ru.properties`. -- [x] Ensure inline updates are no longer logged as unsupported warnings. -- [x] Update `/history`, `/threads`, `/newthread` handlers to work with chat-scoped thread ownership. -- [x] Update `opendaimon-telegram/TELEGRAM_MODULE.md` with new behavior and use cases. -- [x] Add/adjust unit tests for routing, scope resolution, inline guidance, and group command behavior. -- [x] Run compile and target tests for affected modules. - -## Acceptance Criteria -- Group conversation memory is shared across participants through the same `chat.id` thread. -- Mention/reply/command in groups consistently use the same active group thread. -- Inline usage shows a clear, localized instruction to use mention/reply in chat. -- No ambiguity remains between inline transport and dialog business behavior. - -## Notes -- Telegram `inline_query` does not provide `chat_id`, so chat-scoped memory cannot be reliably implemented for inline. -- If future product requirements change, inline can be reintroduced as stateless utility mode. diff --git a/docs/telegram-thinking-modes.md b/docs/telegram-thinking-modes.md index 8c86324b..a214be85 100644 --- a/docs/telegram-thinking-modes.md +++ b/docs/telegram-thinking-modes.md @@ -37,7 +37,7 @@ final transcript as `Tools only`; the difference is strictly in the streaming UX ### Comparison table -| Dimension | Show reasoning | Tools only | Silent | +| Dimension | SHOW_ALL | HIDE_REASONING | SILENT | |---|---|---|---| | `"💭 Thinking..."` placeholder visible during stream | ✅ | ✅ | ❌ | | Reasoning text visible during stream | ✅ (persists) | ✅ (briefly, then overwritten) | ❌ (never rendered) | diff --git a/docs/telegram-two-update-coalescing-plan.md b/docs/telegram-two-update-coalescing-plan.md deleted file mode 100644 index d6aa819e..00000000 --- a/docs/telegram-two-update-coalescing-plan.md +++ /dev/null @@ -1,31 +0,0 @@ -# Telegram Two-Update Coalescing Plan - -## Summary - -Implement coalescing for Telegram split user intents (`first short text` + `second linked forward/media`) -so the bot sends one response instead of two. - -## Progress Checklist - -- [x] SA-1: Add `TelegramMessageCoalescingService` with pending-first buffer + timeout flush -- [x] SA-2: Integrate coalescing pre-step in `TelegramBot.onUpdateReceived` -- [x] SA-3: Implement merge rules (same chat/user, wait window, explicit link required) -- [x] SA-4: Build merged user text payload (`firstText + "\n\n" + secondUserText`) -- [x] SA-5: Add coalescing logs (wait/merge/no-merge/timeout) -- [x] SA-6: Add properties under `open-daimon.telegram.message-coalescing` -- [x] SA-7: Cover new behavior with unit tests -- [x] SA-8: Update `TELEGRAM_MODULE.md` behavior reference - -## Configuration - -- [x] `open-daimon.telegram.message-coalescing.enabled=true` -- [x] `open-daimon.telegram.message-coalescing.wait-window-ms=1200` -- [x] `open-daimon.telegram.message-coalescing.max-leading-text-length=160` -- [x] `open-daimon.telegram.message-coalescing.allow-media-second-message=true` -- [x] `open-daimon.telegram.message-coalescing.require-explicit-link=true` - -## Verification - -- [x] `mvn clean compile` -- [x] `mvn test -pl opendaimon-telegram -am -Dtest=TelegramMessageCoalescingServiceTest,TelegramPropertiesTest -Dsurefire.failIfNoSpecifiedTests=false` -- [x] `mvn clean test -pl opendaimon-telegram` (environment issue in this workspace: Mockito inline ByteBuddy self-attach) diff --git a/org/telegram/telegrambots/meta/api/objects/User.java b/org/telegram/telegrambots/meta/api/objects/User.java deleted file mode 100644 index 54c80520..00000000 --- a/org/telegram/telegrambots/meta/api/objects/User.java +++ /dev/null @@ -1,100 +0,0 @@ -package org.telegram.telegrambots.meta.api.objects; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.Setter; -import lombok.ToString; -import org.telegram.telegrambots.meta.api.interfaces.BotApiObject; - -/** - * This object represents a Telegram user or bot. - * @author Ruben Bermudez - * @version 6.1 - */ -@EqualsAndHashCode(callSuper = false) -@Getter -@Setter -@ToString -@NoArgsConstructor(force = true) -@AllArgsConstructor -@RequiredArgsConstructor -public class User implements BotApiObject { - private static final String ID_FIELD = "id"; - private static final String FIRSTNAME_FIELD = "first_name"; - private static final String ISBOT_FIELD = "is_bot"; - private static final String LASTNAME_FIELD = "last_name"; - private static final String USERNAME_FIELD = "username"; - private static final String LANGUAGECODE_FIELD = "language_code"; - private static final String CANJOINGROUPS_FIELD = "can_join_groups"; - private static final String CANREADALLGROUPMESSAGES_FIELD = "can_read_all_group_messages"; - private static final String SUPPORTINLINEQUERIES_FIELD = "supports_inline_queries"; - private static final String ISPREMIUM_FIELD = "is_premium"; - private static final String ADDEDTOATTACHMENTMENU_FIELD = "added_to_attachment_menu"; - - /** - * Unique identifier for this user or bot. - * - * @apiNote This number may have more than 32 significant bits and some programming languages may have difficulty/silent defects in interpreting it. - * But it has at most 52 significant bits, so a 64-bit integer or double-precision float type are safe for storing this identifier. - */ - @JsonProperty(ID_FIELD) - @NonNull - private Long id; - /** - * User‘s or bot’s first name - */ - @JsonProperty(FIRSTNAME_FIELD) - @NonNull - private String firstName; - /** - * True, if this user is a bot - */ - @JsonProperty(ISBOT_FIELD) - @NonNull - private Boolean isBot; - /** - * Optional. User‘s or bot’s last name - */ - @JsonProperty(LASTNAME_FIELD) - private String lastName; - /** - * Optional. User‘s or bot’s username - */ - @JsonProperty(USERNAME_FIELD) - private String userName; - /** - * Optional. IETF language tag of the user's language - */ - @JsonProperty(LANGUAGECODE_FIELD) - private String languageCode; - /** - * Optional. True, if the bot can be invited to groups. Returned only in getMe. - */ - @JsonProperty(CANJOINGROUPS_FIELD) - private Boolean canJoinGroups; - /** - * Optional. True, if privacy mode is disabled for the bot. Returned only in getMe. - */ - @JsonProperty(CANREADALLGROUPMESSAGES_FIELD) - private Boolean canReadAllGroupMessages; - /** - * Optional. True, if the bot supports inline queries. Returned only in getMe. - */ - @JsonProperty(SUPPORTINLINEQUERIES_FIELD) - private Boolean supportInlineQueries; - /** - * Optional. True, if this user is a Telegram Premium user - */ - @JsonProperty(ISPREMIUM_FIELD) - private Boolean isPremium; - /** - * Optional. True, if this user added the bot to the attachment menu - */ - @JsonProperty(ADDEDTOATTACHMENTMENU_FIELD) - private Boolean addedToAttachmentMenu; -} From c3cfb41f30a2b409afb698753c199e1413a13294 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Fri, 24 Apr 2026 00:04:55 +0300 Subject: [PATCH 08/14] Fixed menu + recent models --- .../UserRecentModelRepositoryIT.java | 188 +++++++++++++ .../common/model/UserRecentModel.java | 80 ++++++ .../repository/UserRecentModelRepository.java | 40 +++ .../core/V15__Add_user_recent_model_table.sql | 15 + opendaimon-telegram/TELEGRAM_MODULE.md | 55 +++- .../opendaimon/telegram/TelegramBot.java | 51 +++- .../impl/ModelTelegramCommandHandler.java | 115 ++++++-- .../telegram/config/TelegramAutoConfig.java | 6 +- .../config/TelegramCommandHandlerConfig.java | 7 +- .../config/TelegramServiceConfig.java | 11 + .../telegram/model/TelegramUser.java | 10 + .../service/TelegramBotMenuService.java | 123 +++++++- .../telegram/service/TelegramUserService.java | 17 ++ .../service/UserRecentModelService.java | 27 ++ .../impl/UserRecentModelServiceImpl.java | 86 ++++++ ...Add_menu_version_hash_to_telegram_user.sql | 6 + .../resources/messages/telegram_en.properties | 1 + .../resources/messages/telegram_ru.properties | 1 + .../opendaimon/telegram/TelegramBotTest.java | 163 +++++++++++ ...elegramTextCommandHandlerProviderTest.java | 6 + .../impl/ModelTelegramCommandHandlerTest.java | 266 ++++++++++++++++++ .../service/TelegramBotMenuServiceTest.java | 111 +++++++- .../impl/UserRecentModelServiceImplTest.java | 172 +++++++++++ 23 files changed, 1515 insertions(+), 42 deletions(-) create mode 100644 opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/repository/UserRecentModelRepositoryIT.java create mode 100644 opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/UserRecentModel.java create mode 100644 opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/repository/UserRecentModelRepository.java create mode 100644 opendaimon-common/src/main/resources/db/migration/core/V15__Add_user_recent_model_table.sql create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/UserRecentModelService.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/impl/UserRecentModelServiceImpl.java create mode 100644 opendaimon-telegram/src/main/resources/db/migration/telegram/V2__Add_menu_version_hash_to_telegram_user.sql create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandlerTest.java create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/impl/UserRecentModelServiceImplTest.java diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/repository/UserRecentModelRepositoryIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/repository/UserRecentModelRepositoryIT.java new file mode 100644 index 00000000..fba86ab7 --- /dev/null +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/repository/UserRecentModelRepositoryIT.java @@ -0,0 +1,188 @@ +package io.github.ngirchev.opendaimon.it.telegram.repository; + +import io.github.ngirchev.opendaimon.common.config.CoreFlywayConfig; +import io.github.ngirchev.opendaimon.common.config.CoreJpaConfig; +import io.github.ngirchev.opendaimon.common.model.UserRecentModel; +import io.github.ngirchev.opendaimon.common.repository.UserRecentModelRepository; +import io.github.ngirchev.opendaimon.telegram.config.TelegramFlywayConfig; +import io.github.ngirchev.opendaimon.telegram.config.TelegramJpaConfig; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserRepository; +import io.github.ngirchev.opendaimon.test.AbstractContainerIT; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({ + CoreJpaConfig.class, + TelegramJpaConfig.class, + CoreFlywayConfig.class, + TelegramFlywayConfig.class +}) +class UserRecentModelRepositoryIT extends AbstractContainerIT { + + @Autowired + private UserRecentModelRepository userRecentModelRepository; + + @Autowired + private TelegramUserRepository telegramUserRepository; + + @Autowired + private EntityManager entityManager; + + @Test + @Transactional + void shouldReturnEntryWhenFoundByUserAndModelName() { + TelegramUser user = saveUser(1L); + + UserRecentModel entry = new UserRecentModel(); + entry.setUser(user); + entry.setModelName("gpt-4"); + entry.setLastUsedAt(OffsetDateTime.now()); + userRecentModelRepository.save(entry); + + Optional found = userRecentModelRepository + .findByUserIdAndModelName(user.getId(), "gpt-4"); + + assertThat(found).isPresent(); + assertThat(found.get().getModelName()).isEqualTo("gpt-4"); + assertThat(found.get().getUser().getId()).isEqualTo(user.getId()); + } + + @Test + @Transactional + void shouldRejectDuplicateUserModelPair() { + TelegramUser user = saveUser(2L); + + UserRecentModel first = new UserRecentModel(); + first.setUser(user); + first.setModelName("claude-opus"); + first.setLastUsedAt(OffsetDateTime.now()); + userRecentModelRepository.save(first); + + UserRecentModel duplicate = new UserRecentModel(); + duplicate.setUser(user); + duplicate.setModelName("claude-opus"); + duplicate.setLastUsedAt(OffsetDateTime.now()); + + assertThatThrownBy(() -> { + userRecentModelRepository.saveAndFlush(duplicate); + }).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @Transactional + void shouldReturnTopEntriesOrderedByLastUsedDesc() { + TelegramUser user = saveUser(3L); + + OffsetDateTime now = OffsetDateTime.now(); + UserRecentModel oldest = save(user, "old-model", now.minusHours(3)); + UserRecentModel middle = save(user, "mid-model", now.minusHours(2)); + UserRecentModel newest = save(user, "new-model", now.minusHours(1)); + entityManager.flush(); + + // Override lastUsedAt via native SQL because @PreUpdate clobbers manual values. + updateLastUsed(oldest.getId(), now.minusHours(3)); + updateLastUsed(middle.getId(), now.minusHours(2)); + updateLastUsed(newest.getId(), now.minusHours(1)); + entityManager.flush(); + entityManager.clear(); + + List top = userRecentModelRepository.findTopByUser( + user.getId(), PageRequest.of(0, 8)); + + assertThat(top).extracting(UserRecentModel::getModelName) + .containsExactly("new-model", "mid-model", "old-model"); + } + + @Test + @Transactional + void shouldDeleteRowsOutsideRetainList() { + TelegramUser user = saveUser(4L); + + UserRecentModel keep = save(user, "keep-me", OffsetDateTime.now()); + UserRecentModel drop = save(user, "drop-me", OffsetDateTime.now().minusDays(1)); + entityManager.flush(); + + int deleted = userRecentModelRepository.deleteByUserIdAndIdNotIn( + user.getId(), List.of(keep.getId())); + entityManager.flush(); + entityManager.clear(); + + assertThat(deleted).isEqualTo(1); + assertThat(userRecentModelRepository.findByUserIdAndModelName(user.getId(), "keep-me")) + .isPresent(); + assertThat(userRecentModelRepository.findByUserIdAndModelName(user.getId(), "drop-me")) + .isEmpty(); + assertThat(drop.getId()).isNotNull(); + } + + @Test + @Transactional + void shouldCascadeDeleteWhenUserRemoved() { + TelegramUser user = saveUser(5L); + save(user, "shadow-model", OffsetDateTime.now()); + entityManager.flush(); + Long userId = user.getId(); + entityManager.clear(); + + // Re-attach via delete-by-id to avoid cascading from a detached graph. + telegramUserRepository.deleteById(userId); + entityManager.flush(); + entityManager.clear(); + + List remaining = userRecentModelRepository.findTopByUser( + userId, PageRequest.of(0, 8)); + assertThat(remaining).isEmpty(); + } + + // Helpers + + private TelegramUser saveUser(long telegramId) { + TelegramUser user = new TelegramUser(); + user.setTelegramId(telegramId); + user.setUsername("u" + telegramId); + user.setFirstName("Recent"); + user.setLastName("Tester"); + user.setCreatedAt(OffsetDateTime.now()); + user.setUpdatedAt(OffsetDateTime.now()); + user.setLastActivityAt(OffsetDateTime.now()); + user.setIsAdmin(false); + user.setIsPremium(false); + user.setIsBlocked(false); + return telegramUserRepository.save(user); + } + + private UserRecentModel save(TelegramUser user, String modelName, OffsetDateTime at) { + UserRecentModel entry = new UserRecentModel(); + entry.setUser(user); + entry.setModelName(modelName); + entry.setLastUsedAt(at); + return userRecentModelRepository.save(entry); + } + + private void updateLastUsed(Long id, OffsetDateTime at) { + entityManager.createNativeQuery( + "UPDATE user_recent_model SET last_used_at = :at WHERE id = :id") + .setParameter("at", at) + .setParameter("id", id) + .executeUpdate(); + } +} diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/UserRecentModel.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/UserRecentModel.java new file mode 100644 index 00000000..2cfabe85 --- /dev/null +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/UserRecentModel.java @@ -0,0 +1,80 @@ +package io.github.ngirchev.opendaimon.common.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.time.OffsetDateTime; + +/** + * Recent AI model picked explicitly by a user via the Telegram {@code /model} menu. + * One row per (user, modelName) pair; upsert semantics enforced by the unique + * constraint on (user_id, model_name). The history is pruned write-side to the + * top-N entries ordered by {@link #lastUsedAt} descending. + */ +@Entity +@Table( + name = "user_recent_model", + uniqueConstraints = @UniqueConstraint( + name = "uk_user_recent_model", + columnNames = {"user_id", "model_name"}), + indexes = @Index( + name = "idx_user_recent_model_user_lastused", + columnList = "user_id, last_used_at DESC") +) +@Getter +@Setter +@ToString(exclude = "user") +@NoArgsConstructor +public class UserRecentModel extends AbstractEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * Owner of the recent-model entry. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + /** + * Model identifier as returned by the gateway (matches {@code ModelInfo.name()}). + */ + @Column(name = "model_name", nullable = false, length = 255) + private String modelName; + + /** + * Timestamp of the most recent explicit pick. Updated on every insert/update + * via {@link #onPersist()} / {@link #onUpdate()}. + */ + @Column(name = "last_used_at", nullable = false) + private OffsetDateTime lastUsedAt; + + @PrePersist + protected void onPersist() { + if (lastUsedAt == null) { + lastUsedAt = OffsetDateTime.now(); + } + } + + @PreUpdate + protected void onUpdate() { + lastUsedAt = OffsetDateTime.now(); + } +} diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/repository/UserRecentModelRepository.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/repository/UserRecentModelRepository.java new file mode 100644 index 00000000..090b89e8 --- /dev/null +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/repository/UserRecentModelRepository.java @@ -0,0 +1,40 @@ +package io.github.ngirchev.opendaimon.common.repository; + +import io.github.ngirchev.opendaimon.common.model.UserRecentModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserRecentModelRepository extends JpaRepository { + + /** + * Looks up an existing recent-model record for (user, modelName). + */ + Optional findByUserIdAndModelName(Long userId, String modelName); + + /** + * Returns up to the top-N most recently used models for the given user, + * ordered by {@code lastUsedAt DESC}. + */ + @Query("SELECT r FROM UserRecentModel r " + + "WHERE r.user.id = :userId " + + "ORDER BY r.lastUsedAt DESC") + List findTopByUser(@Param("userId") Long userId, + org.springframework.data.domain.Pageable pageable); + + /** + * Deletes all entries for the user whose id is not in the given retain list. + * Used to prune history after an upsert so that only the top-N records remain. + */ + @Modifying + @Query("DELETE FROM UserRecentModel r " + + "WHERE r.user.id = :userId AND r.id NOT IN :retainIds") + int deleteByUserIdAndIdNotIn(@Param("userId") Long userId, + @Param("retainIds") List retainIds); +} diff --git a/opendaimon-common/src/main/resources/db/migration/core/V15__Add_user_recent_model_table.sql b/opendaimon-common/src/main/resources/db/migration/core/V15__Add_user_recent_model_table.sql new file mode 100644 index 00000000..b4923c80 --- /dev/null +++ b/opendaimon-common/src/main/resources/db/migration/core/V15__Add_user_recent_model_table.sql @@ -0,0 +1,15 @@ +-- ===================================================== +-- Track recently selected AI models per user. +-- Populated by ModelTelegramCommandHandler on explicit pick; +-- cap is maintained write-side (top 8 by last_used_at). +-- ===================================================== +CREATE TABLE IF NOT EXISTS user_recent_model ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + model_name VARCHAR(255) NOT NULL, + last_used_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT uk_user_recent_model UNIQUE (user_id, model_name) +); + +CREATE INDEX IF NOT EXISTS idx_user_recent_model_user_lastused + ON user_recent_model(user_id, last_used_at DESC); diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index 4ed67118..a2e84bca 100644 --- a/opendaimon-telegram/TELEGRAM_MODULE.md +++ b/opendaimon-telegram/TELEGRAM_MODULE.md @@ -292,16 +292,24 @@ Implementation: `TelegramMessageHandlerActions` orchestrates the two-message sta **Trigger:** `/model` or pressing `🤖 ModelName` keyboard button **Handler:** `ModelTelegramCommandHandler` 1. Creates `ModelListAICommand` → `AIGatewayRegistry` resolves gateway → returns available model list -2. Builds inline keyboard: `AUTO` button + one button per model with capability tags (Vision, Web, Tools, Summary, Free) -3. Button text capped at 64 bytes (Telegram limit); uses index instead of model name in callback data +2. When the model count exceeds page size, builds a two-level menu: `AUTO` + one row per category, with counts. + Category order: `RECENT`, `LOCAL`, `VISION`, `FREE`, `ALL`. + - `RECENT` is populated from `UserRecentModelService.getRecentModels()` (up to 8 most recently picked + models, ordered by `last_used_at DESC`). Hidden when the user has no history yet or when all recent + entries have disappeared from the current gateway model list. + - The remaining four categories use static predicates over `ModelInfo`. +3. For small model counts (≤ page size), shows the flat legacy list with all models plus capability tags. +4. Button text capped at 64 bytes (Telegram limit); uses index instead of model name in callback data. --- ### UC-16: `/model` — select model via callback **Trigger:** `MODEL_` callback -**Handler:** resolves index → model name → `UserModelPreferenceService.setPreferredModel()` +**Handler:** resolves index → model name → `UserModelPreferenceService.setPreferredModel()` → +`UserRecentModelService.recordUsage()` (upsert + prune to top 8) - Sends confirmation with model name - `PersistentKeyboardService.sendKeyboard()` updated with new model +- The just-picked model appears first in the `RECENT` category on the next `/model` invocation. --- @@ -310,6 +318,7 @@ Implementation: `TelegramMessageHandlerActions` orchestrates the two-message sta **Handler:** `UserModelPreferenceService.clearPreference()` - Callback ack uses `telegram.model.ack.auto` (user language) - Persistent keyboard left button uses `telegram.model.auto` when no fixed model is stored +- Does NOT update `user_recent_model` — the Recent list reflects explicit picks only. --- @@ -844,6 +853,7 @@ Table: `telegram_user` (JPA JOINED inheritance, discriminator `TELEGRAM`) |-------|------|-------| | `telegramId` | `Long` | Unique, maps to Telegram chat ID | | `preferredModelId` | `String` | Set by `/model`, null = auto | +| `menuVersionHash` | `String(64)` | SHA-256 of the command set last pushed to Telegram for this chat via `BotCommandScopeChat`. Null when no chat-scoped menu has been set — user falls back to Default scope. See "Lazy per-chat command menu reconciliation". | | Inherited from `User` | | id, languageCode, isPremium, isBlocked, isAdmin, currentAssistantRole, lastActivityAt, … | ### TelegramUserSession @@ -872,6 +882,45 @@ The control that opens the bot command list in the Telegram client is labeled by Session cleanup: `TelegramUserActivityService` runs every 10 minutes, closes sessions inactive > 15 minutes. +### Lazy per-chat command menu reconciliation + +Once a user interacts with `/language`, the bot calls `setMyCommands(..., chatId)` — a +`BotCommandScopeChat` snapshot that overrides the Default-scope menu refreshed at startup. +Because the Default-scope refresh never touches chat-scoped snapshots, a deployment that +adds or removes commands (e.g. new `/mode`, `/thinking`) leaves those users frozen on the +old menu. + +`TelegramBotMenuService#reconcileMenuIfStale(TelegramUser)` repairs this lazily, on the +user's first chat interaction after the deployment: + +| Check | Outcome | +|-------|---------| +| `user.languageCode == null` | skip — user is still on the Default scope, already covered by startup refresh | +| `user.menuVersionHash` equals `currentMenuVersionHash` | skip — nothing to do | +| otherwise | call `setupBotMenuForUser(chatId, languageCode)`, then stamp `user.menuVersionHash = currentMenuVersionHash` | + +`currentMenuVersionHash` is a SHA-256 hex over the deterministic concatenation of +`:\n` lines across every entry in `SupportedLanguages.SUPPORTED_LANGUAGES` +(sorted) and every handler-provided command text (sorted alphabetically within the language). +It is computed lazily on first access and cached for the lifetime of the bean — command +handlers are Spring-managed beans that may not be fully available at service construction time. + +**Wire-in points in `TelegramBot`:** +- `mapToTelegramTextCommand` — inside the `stripped.startsWith("/")` branch, immediately + after `clearStatus(...)`. +- `mapToTelegramCommand` — callback-query path, immediately after `getOrCreateUser(...)`. + +Plain-text messages (UC-1 and friends) do NOT trigger reconciliation — only slash commands +and callback clicks do. This keeps the hot text-message path free of extra DB work. + +Telegram API failures and any unexpected exception inside the reconcile call are swallowed +by `TelegramBot` (logged at `warn`) — the command processing continues. When reconcile +returns `true`, `TelegramBot` persists the new hash via +`TelegramUserService#updateMenuVersionHash(telegramId, hash)`. + +Column: `telegram_user.menu_version_hash VARCHAR(64)`, nullable. Migration +`V2__Add_menu_version_hash_to_telegram_user.sql`. + ## Agent Streaming: Throttling & Rollback Internals ### Rate-limited status edits — `TelegramProgressBatcher` diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java index a17a9001..5c2ebd48 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java @@ -45,6 +45,7 @@ import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import io.github.ngirchev.opendaimon.telegram.model.TelegramUserSession; +import io.github.ngirchev.opendaimon.telegram.service.TelegramBotMenuService; import io.github.ngirchev.opendaimon.telegram.service.TelegramFileService; import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageCoalescingService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; @@ -68,11 +69,12 @@ public class TelegramBot extends TelegramLongPollingBot { private final ObjectProvider fileServiceProvider; private final ObjectProvider fileUploadPropertiesProvider; private final ObjectProvider messageCoalescingServiceProvider; + private final ObjectProvider menuServiceProvider; public TelegramBot(TelegramProperties config, CommandSyncService commandSyncService, TelegramUserService userService) { - this(config, new DefaultBotOptions(), commandSyncService, userService, null, null, null, null); + this(config, new DefaultBotOptions(), commandSyncService, userService, null, null, null, null, null); } /** @@ -86,7 +88,7 @@ public TelegramBot(TelegramProperties config, ObjectProvider fileServiceProvider, ObjectProvider fileUploadPropertiesProvider) { this(config, botOptions, commandSyncService, userService, messageLocalizationService, - fileServiceProvider, fileUploadPropertiesProvider, null); + fileServiceProvider, fileUploadPropertiesProvider, null, null); } public TelegramBot(TelegramProperties config, @@ -97,6 +99,19 @@ public TelegramBot(TelegramProperties config, ObjectProvider fileServiceProvider, ObjectProvider fileUploadPropertiesProvider, ObjectProvider messageCoalescingServiceProvider) { + this(config, botOptions, commandSyncService, userService, messageLocalizationService, + fileServiceProvider, fileUploadPropertiesProvider, messageCoalescingServiceProvider, null); + } + + public TelegramBot(TelegramProperties config, + DefaultBotOptions botOptions, + CommandSyncService commandSyncService, + TelegramUserService userService, + MessageLocalizationService messageLocalizationService, + ObjectProvider fileServiceProvider, + ObjectProvider fileUploadPropertiesProvider, + ObjectProvider messageCoalescingServiceProvider, + ObjectProvider menuServiceProvider) { super(botOptions, config.getToken()); this.config = config; this.commandSyncService = commandSyncService; @@ -105,6 +120,7 @@ public TelegramBot(TelegramProperties config, this.fileServiceProvider = fileServiceProvider; this.fileUploadPropertiesProvider = fileUploadPropertiesProvider; this.messageCoalescingServiceProvider = messageCoalescingServiceProvider; + this.menuServiceProvider = menuServiceProvider; } @Override @@ -461,6 +477,35 @@ private static Integer getReplyToMessageId(Update update) { return null; } + /** + * Lazy per-chat command menu reconciliation. + *

+ * Called from the slash-command and callback-query paths so the first chat-scoped + * interaction after a deployment repairs a stale {@code BotCommandScopeChat} snapshot + * whose command set diverges from the current build. Must not block or throw — any + * Telegram API failure is swallowed at the call site and the command processing continues. + * + * @param user resolved Telegram user (never {@code null}) + */ + private void reconcileMenuIfStale(TelegramUser user) { + if (menuServiceProvider == null) { + return; + } + TelegramBotMenuService menuService = menuServiceProvider.getIfAvailable(); + if (menuService == null) { + return; + } + try { + boolean changed = menuService.reconcileMenuIfStale(user); + if (changed) { + userService.updateMenuVersionHash(user.getTelegramId(), user.getMenuVersionHash()); + } + } catch (Exception e) { + log.warn("Lazy menu reconciliation failed for chatId={}: {}", + user != null ? user.getTelegramId() : null, e.getMessage()); + } + } + /** * Returns whether file upload is enabled. */ @@ -476,6 +521,7 @@ protected TelegramCommand mapToTelegramCommand(Update update) { CallbackQuery cq = update.getCallbackQuery(); var message = cq.getMessage(); TelegramUser telegramUser = userService.getOrCreateUser(cq.getFrom()); + reconcileMenuIfStale(telegramUser); Long userId = telegramUser.getId(); TelegramCommandType telegramCommandType = null; @@ -520,6 +566,7 @@ protected TelegramCommand mapToTelegramTextCommand(Update update) { userText = enrichWithForwardContext(stripped, forwardInfo, telegramUser.getLanguageCode()); } else if (stripped.startsWith("/")) { clearStatus(telegramUser.getTelegramId()); + reconcileMenuIfStale(telegramUser); int spaceIndex = stripped.indexOf(' '); String commandToken = stripped.substring(0, spaceIndex == -1 ? stripped.length() : spaceIndex); String normalizedCommand = normalizeBotCommand(commandToken); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandler.java index 207a5b51..f0b24c57 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandler.java @@ -25,6 +25,7 @@ import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.telegram.service.UserRecentModelService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; import org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery; @@ -40,6 +41,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -65,19 +67,6 @@ public class ModelTelegramCommandHandler extends AbstractTelegramCommandHandlerW ModelCapabilities.FREE ); - private static final List CATEGORY_DEFINITIONS = List.of( - new ModelCategory("LOCAL", "telegram.model.cat.local", - model -> "Ollama".equalsIgnoreCase(model.provider())), - new ModelCategory("VISION", "telegram.model.cat.vision", - model -> model.capabilities().contains(ModelCapabilities.VISION) - && !"Ollama".equalsIgnoreCase(model.provider())), - new ModelCategory("FREE", "telegram.model.cat.free", - model -> model.capabilities().contains(ModelCapabilities.FREE) - && !model.capabilities().contains(ModelCapabilities.VISION) - && !"Ollama".equalsIgnoreCase(model.provider())), - new ModelCategory("ALL", "telegram.model.cat.all", model -> true) - ); - private final TelegramUserService telegramUserService; private final UserModelPreferenceService userModelPreferenceService; private final AIGatewayRegistry aiGatewayRegistry; @@ -85,6 +74,14 @@ public class ModelTelegramCommandHandler extends AbstractTelegramCommandHandlerW private final PersistentKeyboardService persistentKeyboardService; private final ConversationThreadService conversationThreadService; private final ModelSelectionSession modelSelectionSession; + private final UserRecentModelService userRecentModelService; + + /** + * Ordered category catalogue shown in the Level-1 menu. RECENT captures + * {@link #userRecentModelService} so it has to be an instance field rather + * than a {@code static final} list. + */ + private final List categoryDefinitions; public ModelTelegramCommandHandler(ObjectProvider telegramBotProvider, TypingIndicatorService typingIndicatorService, @@ -95,7 +92,8 @@ public ModelTelegramCommandHandler(ObjectProvider telegramBotProvid IUserPriorityService userPriorityService, PersistentKeyboardService persistentKeyboardService, ConversationThreadService conversationThreadService, - ModelSelectionSession modelSelectionSession) { + ModelSelectionSession modelSelectionSession, + UserRecentModelService userRecentModelService) { super(telegramBotProvider, typingIndicatorService, messageLocalizationService); this.telegramUserService = telegramUserService; this.userModelPreferenceService = userModelPreferenceService; @@ -104,6 +102,44 @@ public ModelTelegramCommandHandler(ObjectProvider telegramBotProvid this.persistentKeyboardService = persistentKeyboardService; this.conversationThreadService = conversationThreadService; this.modelSelectionSession = modelSelectionSession; + this.userRecentModelService = userRecentModelService; + this.categoryDefinitions = buildCategoryDefinitions(); + } + + private List buildCategoryDefinitions() { + return List.of( + ModelCategory.dynamic("RECENT", "telegram.model.cat.recent", + (allModels, userId) -> { + List recent = userRecentModelService + .getRecentModels(userId, PAGE_SIZE); + if (recent.isEmpty()) { + return List.of(); + } + Map nameToIdx = indexByName(allModels); + return recent.stream() + .map(nameToIdx::get) + .filter(Objects::nonNull) + .toList(); + }), + ModelCategory.filtered("LOCAL", "telegram.model.cat.local", + model -> "Ollama".equalsIgnoreCase(model.provider())), + ModelCategory.filtered("VISION", "telegram.model.cat.vision", + model -> model.capabilities().contains(ModelCapabilities.VISION) + && !"Ollama".equalsIgnoreCase(model.provider())), + ModelCategory.filtered("FREE", "telegram.model.cat.free", + model -> model.capabilities().contains(ModelCapabilities.FREE) + && !model.capabilities().contains(ModelCapabilities.VISION) + && !"Ollama".equalsIgnoreCase(model.provider())), + ModelCategory.filtered("ALL", "telegram.model.cat.all", model -> true) + ); + } + + private static Map indexByName(List models) { + Map map = new HashMap<>(models.size() * 2); + for (int i = 0; i < models.size(); i++) { + map.put(models.get(i).name(), i); + } + return map; } @Override @@ -158,7 +194,7 @@ private void sendCategoryMenu(Long chatId, TelegramUser user) { return; } - MenuContent menu = buildCategoryMenuContent(models, lang); + MenuContent menu = buildCategoryMenuContent(models, lang, user.getId()); SendMessage msg = new SendMessage(chatId.toString(), menu.text()); msg.setReplyMarkup(menu.markup()); telegramBotProvider.getObject().execute(msg); @@ -202,15 +238,17 @@ private void sendFlatModelList(Long chatId, List models, String lang) /** * Builds category menu content reused by both send and edit flows. + * Categories with an empty resolver result (e.g. {@code RECENT} for a new + * user) are omitted automatically. */ - private MenuContent buildCategoryMenuContent(List models, String lang) { + private MenuContent buildCategoryMenuContent(List models, String lang, Long userId) { List> keyboard = new ArrayList<>(); keyboard.add(List.of(createButton( messageLocalizationService.getMessage("telegram.model.auto", lang), CALLBACK_AUTO))); - for (ModelCategory category : CATEGORY_DEFINITIONS) { - long count = models.stream().filter(category.filter()).count(); + for (ModelCategory category : categoryDefinitions) { + int count = category.resolver().resolve(models, userId).size(); if (count == 0) { continue; } @@ -240,10 +278,7 @@ private void showCategoryPage(Long chatId, Integer messageId, TelegramUser user, return; } - List matchingIndices = IntStream.range(0, allModels.size()) - .filter(i -> category.filter().test(allModels.get(i))) - .boxed() - .toList(); + List matchingIndices = category.resolver().resolve(allModels, user.getId()); if (matchingIndices.isEmpty()) { log.warn("Empty category '{}' for chat={}", categoryKey, chatId); @@ -378,6 +413,7 @@ private void handleCallbackQuery(TelegramCommand command) { // Model selection: MODEL_ String modelName = resolveModelName(callbackData, user); userModelPreferenceService.setPreferredModel(userId, modelName); + userRecentModelService.recordUsage(userId, modelName); ackCallback(cq.getId(), "✅ " + modelName); deleteMenuMessage(command.telegramId(), cq); modelSelectionSession.evict(userId); @@ -387,7 +423,7 @@ private void handleCallbackQuery(TelegramCommand command) { private void editToCategoryMenu(Long chatId, Integer messageId, TelegramUser user) { try { List models = fetchModels(user); - MenuContent menu = buildCategoryMenuContent(models, user.getLanguageCode()); + MenuContent menu = buildCategoryMenuContent(models, user.getLanguageCode(), user.getId()); editMenuMessage(chatId, messageId, menu.text(), menu.markup()); } catch (Exception e) { log.error("Failed to edit category menu: {}", e.getMessage(), e); @@ -454,7 +490,7 @@ private static String formatProviderPrefix(String provider) { } private ModelCategory findCategory(String key) { - return CATEGORY_DEFINITIONS.stream() + return categoryDefinitions.stream() .filter(c -> c.key().equals(key)) .findFirst() .orElse(null); @@ -524,7 +560,36 @@ public String getSupportedCommandText(String languageCode) { return messageLocalizationService.getMessage("telegram.command.model.desc", languageCode); } - private record ModelCategory(String key, String labelKey, Predicate filter) {} + /** + * Resolves the ordered list of model indices that belong to a category, + * given the full model list and the user viewing the menu. + */ + @FunctionalInterface + interface IndexResolver { + List resolve(List allModels, Long userId); + } + + private record ModelCategory(String key, String labelKey, IndexResolver resolver) { + + /** + * Category whose members are fully determined by a per-model predicate; + * order follows the natural order of {@code allModels}. + */ + static ModelCategory filtered(String key, String labelKey, Predicate filter) { + return new ModelCategory(key, labelKey, + (allModels, userId) -> IntStream.range(0, allModels.size()) + .filter(i -> filter.test(allModels.get(i))) + .boxed() + .toList()); + } + + /** + * Category with custom resolver (e.g. user-specific history). + */ + static ModelCategory dynamic(String key, String labelKey, IndexResolver resolver) { + return new ModelCategory(key, labelKey, resolver); + } + } private record MenuContent(String text, InlineKeyboardMarkup markup) {} } diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java index 87900eca..c981f6e6 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java @@ -47,7 +47,8 @@ public TelegramBot telegramBot(TelegramProperties properties, MessageLocalizationService messageLocalizationService, ObjectProvider fileServiceProvider, ObjectProvider fileUploadPropertiesProvider, - ObjectProvider messageCoalescingServiceProvider) { + ObjectProvider messageCoalescingServiceProvider, + ObjectProvider menuServiceProvider) { Integer socketTimeoutSec = properties.getLongPollingSocketTimeoutSeconds(); Integer getUpdatesTimeoutSec = properties.getGetUpdatesTimeoutSeconds(); DefaultBotOptions options = new DefaultBotOptions(); @@ -62,7 +63,8 @@ public TelegramBot telegramBot(TelegramProperties properties, options.setRequestConfig(requestConfig); } return new TelegramBot(properties, options, commandSyncService, userService, - messageLocalizationService, fileServiceProvider, fileUploadPropertiesProvider, messageCoalescingServiceProvider); + messageLocalizationService, fileServiceProvider, fileUploadPropertiesProvider, + messageCoalescingServiceProvider, menuServiceProvider); } @Bean diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java index 5b8af760..dccf3ad8 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java @@ -33,6 +33,7 @@ import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.telegram.service.UserRecentModelService; import io.github.ngirchev.opendaimon.telegram.service.TelegramFileService; import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageService; import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserRepository; @@ -315,7 +316,8 @@ public ModelTelegramCommandHandler modelTelegramCommandHandler( IUserPriorityService userPriorityService, PersistentKeyboardService persistentKeyboardService, ConversationThreadService conversationThreadService, - ModelSelectionSession modelSelectionSession) { + ModelSelectionSession modelSelectionSession, + UserRecentModelService userRecentModelService) { return new ModelTelegramCommandHandler( telegramBotProvider, typingIndicatorService, @@ -326,7 +328,8 @@ public ModelTelegramCommandHandler modelTelegramCommandHandler( userPriorityService, persistentKeyboardService, conversationThreadService, - modelSelectionSession + modelSelectionSession, + userRecentModelService ); } } \ No newline at end of file diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java index 120550e6..207d724b 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java @@ -15,6 +15,8 @@ import io.github.ngirchev.opendaimon.common.config.CoreCommonProperties; import io.github.ngirchev.opendaimon.common.meter.OpenDaimonMeterRegistry; import io.github.ngirchev.opendaimon.common.repository.ConversationThreadRepository; +import io.github.ngirchev.opendaimon.common.repository.UserRecentModelRepository; +import io.github.ngirchev.opendaimon.common.repository.UserRepository; import io.github.ngirchev.opendaimon.common.service.AssistantRoleService; import io.github.ngirchev.opendaimon.common.service.ConversationThreadService; import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; @@ -27,6 +29,7 @@ import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserSessionRepository; import io.github.ngirchev.opendaimon.telegram.repository.TelegramWhitelistRepository; import io.github.ngirchev.opendaimon.telegram.service.*; +import io.github.ngirchev.opendaimon.telegram.service.impl.UserRecentModelServiceImpl; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -187,4 +190,12 @@ public TelegramFileService telegramFileService( FileUploadProperties fileUploadProperties) { return new TelegramFileService(telegramBotProvider, fileStorageServiceProvider, fileUploadProperties); } + + @Bean + @ConditionalOnMissingBean + public UserRecentModelService userRecentModelService( + UserRecentModelRepository userRecentModelRepository, + UserRepository userRepository) { + return new UserRecentModelServiceImpl(userRecentModelRepository, userRepository); + } } diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/model/TelegramUser.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/model/TelegramUser.java index 244db774..f0cbfbfa 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/model/TelegramUser.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/model/TelegramUser.java @@ -19,6 +19,16 @@ public class TelegramUser extends User { @Column(name = "telegram_id", unique = true, nullable = false) private Long telegramId; + /** + * SHA-256 hex of the command set last pushed to Telegram for this chat via + * {@code BotCommandScopeChat}. Null when no chat-scoped menu has ever been set — + * in that case Telegram falls back to the Default-scope menu maintained at startup. + *

+ * See {@code TelegramBotMenuService#reconcileMenuIfStale} for the update path. + */ + @Column(name = "menu_version_hash", length = 64) + private String menuVersionHash; + @Override public Long getId() { return super.getId(); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java index 988efd3e..6fee1379 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java @@ -7,12 +7,17 @@ import org.telegram.telegrambots.meta.exceptions.TelegramApiException; import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramSupportedCommandProvider; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import io.github.ngirchev.opendaimon.common.SupportedLanguages; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.TreeSet; /** * Service for setting up Telegram bot command menu. @@ -24,6 +29,15 @@ public class TelegramBotMenuService { private final ObjectProvider telegramBotProvider; private final ObjectProvider commandHandlersProvider; + /** + * Cached hash of the current enabled-commands set. Computed lazily on first access + * because command handler beans are registered as part of application context startup + * and may not be fully available at this service's construction time. + *

+ * Double-checked locking with a {@code volatile} reference; value is set once per JVM. + */ + private volatile String currentMenuVersionHash; + /** * Sets bot command menu for each supported language. Telegram shows the menu in the user's app language. */ @@ -63,6 +77,87 @@ public void setupBotMenuForUser(Long chatId, String languageCode) { } } + /** + * Returns a stable SHA-256 hex digest of the currently enabled command set, computed + * over every supported language. Used as a per-user marker to detect that a chat-scoped + * menu (set via {@code BotCommandScopeChat}) is stale after a deployment adds or removes + * commands. + *

+ * Computed lazily on first access and cached; never recomputed afterwards for the lifetime + * of this bean. + * + * @return 64-char lowercase hex string + */ + public String getCurrentMenuVersionHash() { + String cached = currentMenuVersionHash; + if (cached != null) { + return cached; + } + synchronized (this) { + if (currentMenuVersionHash == null) { + currentMenuVersionHash = computeCurrentMenuVersionHash(); + } + return currentMenuVersionHash; + } + } + + /** + * Deterministic hash of the command set across every supported language. Languages are + * iterated in sorted order; within each language, the handler-provided command texts are + * sorted alphabetically. Each entry is encoded as {@code ":\n"}. + *

+ * Package-private for testing. + */ + String computeCurrentMenuVersionHash() { + StringBuilder payload = new StringBuilder(); + TreeSet sortedLanguages = new TreeSet<>(SupportedLanguages.SUPPORTED_LANGUAGES); + for (String lang : sortedLanguages) { + TreeSet commandTexts = new TreeSet<>(); + commandHandlersProvider.orderedStream() + .map(h -> h.getSupportedCommandText(lang)) + .filter(Objects::nonNull) + .forEach(commandTexts::add); + for (String commandText : commandTexts) { + payload.append(lang).append(':').append(commandText).append('\n'); + } + } + return sha256Hex(payload.toString()); + } + + /** + * Reconciles the chat-scoped command menu for the given user if it differs from the + * current menu version. No-op when the user has no language code (they rely on the + * Default-scope menu refreshed at startup) or when the stored hash already matches. + *

+ * Caller is responsible for persisting {@code user} after this method returns {@code true}. + * Telegram API failures are swallowed internally (already handled in {@code setupBotMenuForUser}) + * and surfaced only via logs — this method never propagates a checked exception to callers. + * + * @param user telegram user whose chat menu may need refreshing + * @return {@code true} if the menu was refreshed and {@code user.menuVersionHash} was updated; + * {@code false} if no work was needed + */ + public boolean reconcileMenuIfStale(TelegramUser user) { + if (user == null) { + return false; + } + String languageCode = user.getLanguageCode(); + if (languageCode == null) { + // User is still on Default-scope menu; startup refresh already covers them. + return false; + } + String currentHash = getCurrentMenuVersionHash(); + String storedHash = user.getMenuVersionHash(); + if (storedHash != null && storedHash.equals(currentHash)) { + return false; + } + setupBotMenuForUser(user.getTelegramId(), languageCode); + user.setMenuVersionHash(currentHash); + log.info("Reconciled menu for chatId={}: versionHash updated from {} to {}", + user.getTelegramId(), storedHash, currentHash); + return true; + } + /** * Builds list of commands from handlers for the given language. */ @@ -89,30 +184,44 @@ private BotCommand parseCommandText(String commandText) { if (commandText == null || commandText.trim().isEmpty()) { return null; } - + String trimmed = commandText.trim(); int dashIndex = trimmed.indexOf(" - "); - + if (dashIndex == -1) { // If no description, use command as is String command = trimmed.startsWith("/") ? trimmed : "/" + trimmed; return new BotCommand(command, ""); } - + String command = trimmed.substring(0, dashIndex).trim(); String description = trimmed.substring(dashIndex + 3).trim(); - + // Ensure command starts with / if (!command.startsWith("/")) { command = "/" + command; } - + // Limit description length (Telegram max 256 chars) if (description.length() > 256) { description = description.substring(0, 253) + "..."; } - + return new BotCommand(command, description); } -} + private static String sha256Hex(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is a MUST-have in every JVM; this branch is effectively unreachable. + throw new IllegalStateException("SHA-256 algorithm not available", e); + } + } +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java index e8df0a66..3fd3345e 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java @@ -183,6 +183,23 @@ public void updateUserSession(TelegramUser user, String botStatus) { telegramUserSessionService.updateSessionStatus(user, botStatus); } + /** + * Persists the chat-scoped command menu version marker for the user. + * Used by lazy per-chat menu reconciliation after a deployment changes the enabled command set. + * + * @param telegramId Telegram user id + * @param hash new menu version hash, or {@code null} to reset + */ + @Transactional + public void updateMenuVersionHash(Long telegramId, String hash) { + TelegramUser user = telegramUserRepository.findByTelegramId(telegramId) + .orElseThrow(() -> new RuntimeException(USER_NOT_FOUND)); + user.setMenuVersionHash(hash); + OffsetDateTime now = OffsetDateTime.now(); + user.setUpdatedAt(now); + telegramUserRepository.save(user); + } + @Transactional public TelegramUserSession getOrCreateSession(User telegramUser) { TelegramUser user = getOrCreateUserInner(telegramUser); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/UserRecentModelService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/UserRecentModelService.java new file mode 100644 index 00000000..796de0c8 --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/UserRecentModelService.java @@ -0,0 +1,27 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import java.util.List; + +/** + * Tracks recently picked AI models per user so the {@code /model} menu can + * offer a "Recent" shortcut category. Written only on explicit user choice + * (not on {@code Auto} reset). + */ +public interface UserRecentModelService { + + /** + * Upsert-records an explicit model pick. Updates {@code lastUsedAt} if the + * pair (user, modelName) already exists, inserts a new row otherwise, and + * prunes the user's history to the top entries so the table stays bounded. + * + * @param userId internal user id ({@code user.id}) + * @param modelName gateway-provided model identifier + */ + void recordUsage(Long userId, String modelName); + + /** + * Returns up to {@code limit} recent model names for the user, ordered by + * most recent first. Empty list if the user has no history yet. + */ + List getRecentModels(Long userId, int limit); +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/impl/UserRecentModelServiceImpl.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/impl/UserRecentModelServiceImpl.java new file mode 100644 index 00000000..e1274233 --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/impl/UserRecentModelServiceImpl.java @@ -0,0 +1,86 @@ +package io.github.ngirchev.opendaimon.telegram.service.impl; + +import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.common.model.UserRecentModel; +import io.github.ngirchev.opendaimon.common.repository.UserRecentModelRepository; +import io.github.ngirchev.opendaimon.common.repository.UserRepository; +import io.github.ngirchev.opendaimon.telegram.service.UserRecentModelService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +@Slf4j +@RequiredArgsConstructor +public class UserRecentModelServiceImpl implements UserRecentModelService { + + /** + * Maximum number of recent-model rows retained per user. Keeping this value + * aligned with {@code ModelTelegramCommandHandler.PAGE_SIZE} avoids the need + * for extra pagination inside the "Recent" category. + */ + public static final int RECENT_CAP = 8; + + private final UserRecentModelRepository userRecentModelRepository; + private final UserRepository userRepository; + + @Override + @Transactional + public void recordUsage(Long userId, String modelName) { + if (userId == null || modelName == null || modelName.isBlank()) { + log.warn("Skipping recordUsage for userId={} modelName='{}'", userId, modelName); + return; + } + + Optional existing = userRecentModelRepository + .findByUserIdAndModelName(userId, modelName); + + if (existing.isPresent()) { + UserRecentModel entry = existing.get(); + entry.setLastUsedAt(OffsetDateTime.now()); + userRecentModelRepository.save(entry); + } else { + UserRecentModel entry = new UserRecentModel(); + User userRef = userRepository.getReferenceById(userId); + entry.setUser(userRef); + entry.setModelName(modelName); + entry.setLastUsedAt(OffsetDateTime.now()); + userRecentModelRepository.save(entry); + } + + pruneBeyondCap(userId); + } + + @Override + @Transactional(readOnly = true) + public List getRecentModels(Long userId, int limit) { + if (userId == null || limit <= 0) { + return List.of(); + } + Pageable page = PageRequest.of(0, limit); + return userRecentModelRepository.findTopByUser(userId, page).stream() + .map(UserRecentModel::getModelName) + .toList(); + } + + /** + * Retains only the top-{@link #RECENT_CAP} most recent entries for the user; + * deletes everything older. Performed after each upsert so the table is + * bounded regardless of concurrent history size. + */ + private void pruneBeyondCap(Long userId) { + Pageable page = PageRequest.of(0, RECENT_CAP); + List retainIds = userRecentModelRepository.findTopByUser(userId, page).stream() + .map(UserRecentModel::getId) + .toList(); + if (retainIds.isEmpty()) { + return; + } + userRecentModelRepository.deleteByUserIdAndIdNotIn(userId, retainIds); + } +} diff --git a/opendaimon-telegram/src/main/resources/db/migration/telegram/V2__Add_menu_version_hash_to_telegram_user.sql b/opendaimon-telegram/src/main/resources/db/migration/telegram/V2__Add_menu_version_hash_to_telegram_user.sql new file mode 100644 index 00000000..3315d71d --- /dev/null +++ b/opendaimon-telegram/src/main/resources/db/migration/telegram/V2__Add_menu_version_hash_to_telegram_user.sql @@ -0,0 +1,6 @@ +-- Per-chat Telegram command menu reconciliation marker. +-- Holds the SHA-256 hex of the command set (per language) that was last pushed to Telegram +-- via BotCommandScopeChat for this user. Nullable: users that never had a chat-scoped menu +-- set (language not yet chosen) stay on the Default scope and do not need reconciliation. +ALTER TABLE telegram_user + ADD COLUMN IF NOT EXISTS menu_version_hash VARCHAR(64); diff --git a/opendaimon-telegram/src/main/resources/messages/telegram_en.properties b/opendaimon-telegram/src/main/resources/messages/telegram_en.properties index ba21c25d..b1467f95 100644 --- a/opendaimon-telegram/src/main/resources/messages/telegram_en.properties +++ b/opendaimon-telegram/src/main/resources/messages/telegram_en.properties @@ -34,6 +34,7 @@ telegram.model.cap.summary=Summary telegram.model.cap.free=Free telegram.model.cancel=\u274C Cancel telegram.model.categories=Choose model category: +telegram.model.cat.recent=Recent telegram.model.cat.local=Local / Ollama telegram.model.cat.vision=Vision Models telegram.model.cat.free=Free Models diff --git a/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties b/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties index e47a705f..09884cd0 100644 --- a/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties +++ b/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties @@ -34,6 +34,7 @@ telegram.model.cap.summary=Сводка telegram.model.cap.free=Бесплатно telegram.model.cancel=\u274C Отмена telegram.model.categories=Выберите категорию модели: +telegram.model.cat.recent=Недавние telegram.model.cat.local=Локальные / Ollama telegram.model.cat.vision=Изображения telegram.model.cat.free=Бесплатные diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/TelegramBotTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/TelegramBotTest.java index f1804b81..4c03eb42 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/TelegramBotTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/TelegramBotTest.java @@ -9,6 +9,7 @@ import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import io.github.ngirchev.opendaimon.telegram.model.TelegramUserSession; +import io.github.ngirchev.opendaimon.telegram.service.TelegramBotMenuService; import io.github.ngirchev.opendaimon.telegram.service.TelegramFileService; import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageCoalescingService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; @@ -997,4 +998,166 @@ void mapToTelegramDocumentCommand_whenBlankCaption_usesLocalizedFallbackPrompt() assertEquals("Analyze this document and provide a brief summary.", command.userText()); } + // ── Lazy menu reconciliation wire-in ───────────────────────────────── + + @Test + void shouldReconcileMenuAndPersistHashWhenSlashCommandArrivesAndReconcileReturnsTrue() { + TelegramBotMenuService menuService = mock(TelegramBotMenuService.class); + @SuppressWarnings("unchecked") + ObjectProvider menuServiceProvider = mock(ObjectProvider.class); + when(menuServiceProvider.getIfAvailable()).thenReturn(menuService); + + TelegramBot reconcilingBot = new TelegramBot( + config, new DefaultBotOptions(), commandSyncService, userService, + messageLocalizationService, fileServiceProvider, fileUploadPropertiesProvider, + coalescingServiceProvider, menuServiceProvider); + + Update update = new Update(); + Message message = new Message(); + message.setMessageId(1); + Chat chat = new Chat(); + chat.setId(100L); + message.setChat(chat); + User from = new User(200L, "u", false); + message.setFrom(from); + message.setText("/start"); + update.setMessage(message); + + TelegramUser telegramUser = new TelegramUser(); + telegramUser.setId(1L); + telegramUser.setTelegramId(200L); + telegramUser.setLanguageCode("en"); + when(userService.getOrCreateUser(any(User.class))).thenReturn(telegramUser); + when(menuService.reconcileMenuIfStale(any(TelegramUser.class))).thenAnswer(inv -> { + TelegramUser u = inv.getArgument(0); + u.setMenuVersionHash("fresh-hash"); + return true; + }); + + reconcilingBot.mapToTelegramTextCommand(update); + + verify(menuService).reconcileMenuIfStale(telegramUser); + verify(userService).updateMenuVersionHash(200L, "fresh-hash"); + } + + @Test + void shouldNotPersistHashWhenSlashCommandArrivesAndReconcileReturnsFalse() { + TelegramBotMenuService menuService = mock(TelegramBotMenuService.class); + @SuppressWarnings("unchecked") + ObjectProvider menuServiceProvider = mock(ObjectProvider.class); + when(menuServiceProvider.getIfAvailable()).thenReturn(menuService); + + TelegramBot reconcilingBot = new TelegramBot( + config, new DefaultBotOptions(), commandSyncService, userService, + messageLocalizationService, fileServiceProvider, fileUploadPropertiesProvider, + coalescingServiceProvider, menuServiceProvider); + + Update update = new Update(); + Message message = new Message(); + message.setMessageId(1); + Chat chat = new Chat(); + chat.setId(100L); + message.setChat(chat); + User from = new User(200L, "u", false); + message.setFrom(from); + message.setText("/start"); + update.setMessage(message); + + TelegramUser telegramUser = new TelegramUser(); + telegramUser.setId(1L); + telegramUser.setTelegramId(200L); + telegramUser.setLanguageCode("en"); + when(userService.getOrCreateUser(any(User.class))).thenReturn(telegramUser); + when(menuService.reconcileMenuIfStale(any(TelegramUser.class))).thenReturn(false); + + reconcilingBot.mapToTelegramTextCommand(update); + + verify(menuService).reconcileMenuIfStale(telegramUser); + verify(userService, never()).updateMenuVersionHash(anyLong(), anyString()); + } + + @Test + void shouldReconcileMenuAndPersistHashOnCallbackQueryWhenReconcileReturnsTrue() { + TelegramBotMenuService menuService = mock(TelegramBotMenuService.class); + @SuppressWarnings("unchecked") + ObjectProvider menuServiceProvider = mock(ObjectProvider.class); + when(menuServiceProvider.getIfAvailable()).thenReturn(menuService); + + TelegramBot reconcilingBot = new TelegramBot( + config, new DefaultBotOptions(), commandSyncService, userService, + messageLocalizationService, fileServiceProvider, fileUploadPropertiesProvider, + coalescingServiceProvider, menuServiceProvider); + + Update update = new Update(); + org.telegram.telegrambots.meta.api.objects.CallbackQuery cq = + new org.telegram.telegrambots.meta.api.objects.CallbackQuery(); + cq.setId("cq1"); + cq.setData("ROLE_DEFAULT"); + User from = new User(200L, "u", false); + cq.setFrom(from); + Message msg = new Message(); + msg.setMessageId(1); + Chat chat = new Chat(); + chat.setId(100L); + msg.setChat(chat); + cq.setMessage(msg); + update.setCallbackQuery(cq); + + TelegramUser telegramUser = new TelegramUser(); + telegramUser.setId(1L); + telegramUser.setTelegramId(200L); + telegramUser.setLanguageCode("en"); + when(userService.getOrCreateUser(any(User.class))).thenReturn(telegramUser); + TelegramUserSession session = new TelegramUserSession(); + session.setBotStatus(null); + when(userService.getOrCreateSession(any(User.class))).thenReturn(session); + when(menuService.reconcileMenuIfStale(any(TelegramUser.class))).thenAnswer(inv -> { + TelegramUser u = inv.getArgument(0); + u.setMenuVersionHash("fresh-hash"); + return true; + }); + + reconcilingBot.mapToTelegramCommand(update); + + verify(menuService).reconcileMenuIfStale(telegramUser); + verify(userService).updateMenuVersionHash(200L, "fresh-hash"); + } + + @Test + void shouldSwallowReconcileExceptionAndContinueProcessingSlashCommand() { + TelegramBotMenuService menuService = mock(TelegramBotMenuService.class); + @SuppressWarnings("unchecked") + ObjectProvider menuServiceProvider = mock(ObjectProvider.class); + when(menuServiceProvider.getIfAvailable()).thenReturn(menuService); + + TelegramBot reconcilingBot = new TelegramBot( + config, new DefaultBotOptions(), commandSyncService, userService, + messageLocalizationService, fileServiceProvider, fileUploadPropertiesProvider, + coalescingServiceProvider, menuServiceProvider); + + Update update = new Update(); + Message message = new Message(); + message.setMessageId(1); + Chat chat = new Chat(); + chat.setId(100L); + message.setChat(chat); + User from = new User(200L, "u", false); + message.setFrom(from); + message.setText("/start"); + update.setMessage(message); + + TelegramUser telegramUser = new TelegramUser(); + telegramUser.setId(1L); + telegramUser.setTelegramId(200L); + telegramUser.setLanguageCode("en"); + when(userService.getOrCreateUser(any(User.class))).thenReturn(telegramUser); + when(menuService.reconcileMenuIfStale(any(TelegramUser.class))) + .thenThrow(new RuntimeException("reconcile blew up")); + + TelegramCommand cmd = reconcilingBot.mapToTelegramTextCommand(update); + + assertNotNull(cmd); + assertEquals("/start", cmd.commandType().command()); + verify(userService, never()).updateMenuVersionHash(anyLong(), anyString()); + } } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/StartTelegramTextCommandHandlerProviderTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/StartTelegramTextCommandHandlerProviderTest.java index cdf7d888..0ed59b02 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/StartTelegramTextCommandHandlerProviderTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/StartTelegramTextCommandHandlerProviderTest.java @@ -37,6 +37,7 @@ import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; +import io.github.ngirchev.opendaimon.telegram.service.UserRecentModelService; import java.util.List; import java.util.concurrent.ScheduledExecutorService; @@ -301,6 +302,11 @@ public IUserPriorityService userPriorityService() { public TelegramBotMenuService telegramBotMenuService() { return mock(TelegramBotMenuService.class); } + + @Bean + public UserRecentModelService userRecentModelService() { + return mock(UserRecentModelService.class); + } } } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandlerTest.java new file mode 100644 index 00000000..a60f4627 --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandlerTest.java @@ -0,0 +1,266 @@ +package io.github.ngirchev.opendaimon.telegram.command.handler.impl; + +import io.github.ngirchev.opendaimon.bulkhead.model.UserPriority; +import io.github.ngirchev.opendaimon.bulkhead.service.IUserPriorityService; +import io.github.ngirchev.opendaimon.common.ai.ModelCapabilities; +import io.github.ngirchev.opendaimon.common.ai.model.ModelInfo; +import io.github.ngirchev.opendaimon.common.ai.response.ModelListAIResponse; +import io.github.ngirchev.opendaimon.common.service.AIGateway; +import io.github.ngirchev.opendaimon.common.service.AIGatewayRegistry; +import io.github.ngirchev.opendaimon.common.service.ConversationThreadService; +import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; +import io.github.ngirchev.opendaimon.telegram.TelegramBot; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.service.ModelSelectionSession; +import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; +import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; +import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.telegram.service.UserRecentModelService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.beans.factory.ObjectProvider; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.objects.CallbackQuery; +import org.telegram.telegrambots.meta.api.objects.Message; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.api.objects.User; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ModelTelegramCommandHandlerTest { + + private static final Long CHAT_ID = 100500L; + private static final Long USER_ID = 7L; + + @Mock private ObjectProvider telegramBotProvider; + @Mock private TelegramBot telegramBot; + @Mock private TypingIndicatorService typingIndicatorService; + @Mock private MessageLocalizationService messageLocalizationService; + @Mock private TelegramUserService telegramUserService; + @Mock private UserModelPreferenceService userModelPreferenceService; + @Mock private AIGatewayRegistry aiGatewayRegistry; + @Mock private IUserPriorityService userPriorityService; + @Mock private PersistentKeyboardService persistentKeyboardService; + @Mock private ConversationThreadService conversationThreadService; + @Mock private ModelSelectionSession modelSelectionSession; + @Mock private UserRecentModelService userRecentModelService; + @Mock private AIGateway aiGateway; + + private ModelTelegramCommandHandler handler; + + @BeforeEach + void setUp() { + when(telegramBotProvider.getObject()).thenReturn(telegramBot); + when(messageLocalizationService.getMessage(anyString(), anyString())) + .thenAnswer(inv -> inv.getArgument(0)); + when(messageLocalizationService.getMessage(anyString(), anyString(), any())) + .thenAnswer(inv -> inv.getArgument(0)); + when(messageLocalizationService.getMessage(anyString(), anyString(), any(), any(), any())) + .thenAnswer(inv -> inv.getArgument(0)); + when(userPriorityService.getUserPriority(USER_ID)).thenReturn(UserPriority.REGULAR); + + handler = new ModelTelegramCommandHandler( + telegramBotProvider, + typingIndicatorService, + messageLocalizationService, + telegramUserService, + userModelPreferenceService, + aiGatewayRegistry, + userPriorityService, + persistentKeyboardService, + conversationThreadService, + modelSelectionSession, + userRecentModelService); + } + + @Test + void shouldPlaceRecentFirstWhenHistoryNonEmpty() throws TelegramApiException { + List models = buildNineModels(); + stubModelFetch(models); + when(userRecentModelService.getRecentModels(eq(USER_ID), eq(8))) + .thenReturn(List.of("model-0", "model-3")); + + handler.handleInner(buildPlainModelCommand()); + + InlineKeyboardMarkup markup = captureSentMarkup(); + // Row 0: AUTO. Row 1 must be RECENT category button. + String firstCategoryData = markup.getKeyboard().get(1).get(0).getCallbackData(); + assertThat(firstCategoryData).isEqualTo("MODEL_C_RECENT"); + } + + @Test + void shouldHideRecentCategoryWhenHistoryEmpty() throws TelegramApiException { + List models = buildNineModels(); + stubModelFetch(models); + when(userRecentModelService.getRecentModels(eq(USER_ID), eq(8))) + .thenReturn(List.of()); + + handler.handleInner(buildPlainModelCommand()); + + InlineKeyboardMarkup markup = captureSentMarkup(); + boolean hasRecent = markup.getKeyboard().stream() + .flatMap(List::stream) + .map(InlineKeyboardButton::getCallbackData) + .anyMatch(d -> d != null && d.equals("MODEL_C_RECENT")); + assertThat(hasRecent).isFalse(); + } + + @Test + void shouldSkipRecentModelsMissingFromGateway() throws TelegramApiException { + List models = buildNineModels(); + stubModelFetch(models); + // model-0 exists, ghost-model is gone from gateway + when(userRecentModelService.getRecentModels(eq(USER_ID), eq(8))) + .thenReturn(List.of("model-0", "ghost-model")); + + handler.handleInner(buildPlainModelCommand()); + + InlineKeyboardMarkup markup = captureSentMarkup(); + // RECENT still shown (non-empty), but the count label is the LAST localized arg. + // Indirect check: just confirm the RECENT button is present (count comes from label key). + boolean recentRow = markup.getKeyboard().stream() + .flatMap(List::stream) + .map(InlineKeyboardButton::getCallbackData) + .anyMatch("MODEL_C_RECENT"::equals); + assertThat(recentRow).isTrue(); + } + + @Test + void shouldRecordUsageOnExplicitPick() { + List models = buildNineModels(); + when(modelSelectionSession.getOrFetch(eq(USER_ID), any())).thenReturn(models); + + TelegramUser user = buildUser(); + User from = mock(User.class); + when(from.getId()).thenReturn(USER_ID); + when(telegramUserService.getOrCreateUser(from)).thenReturn(user); + + CallbackQuery cq = mock(CallbackQuery.class); + when(cq.getData()).thenReturn("MODEL_2"); + when(cq.getFrom()).thenReturn(from); + when(cq.getId()).thenReturn("cq-1"); + Message msg = mock(Message.class); + when(msg.getMessageId()).thenReturn(42); + when(cq.getMessage()).thenReturn(msg); + + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + when(update.getCallbackQuery()).thenReturn(cq); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, + new TelegramCommandType(TelegramCommand.MODEL), update); + command.languageCode("en"); + + handler.handleInner(command); + + verify(userModelPreferenceService).setPreferredModel(USER_ID, "model-2"); + verify(userRecentModelService).recordUsage(USER_ID, "model-2"); + } + + @Test + void shouldNotRecordUsageOnAutoPick() { + TelegramUser user = buildUser(); + User from = mock(User.class); + when(from.getId()).thenReturn(USER_ID); + when(telegramUserService.getOrCreateUser(from)).thenReturn(user); + + CallbackQuery cq = mock(CallbackQuery.class); + when(cq.getData()).thenReturn("MODEL_AUTO"); + when(cq.getFrom()).thenReturn(from); + when(cq.getId()).thenReturn("cq-auto"); + Message msg = mock(Message.class); + when(msg.getMessageId()).thenReturn(77); + when(cq.getMessage()).thenReturn(msg); + + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(true); + when(update.getCallbackQuery()).thenReturn(cq); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, + new TelegramCommandType(TelegramCommand.MODEL), update); + command.languageCode("en"); + + handler.handleInner(command); + + verify(userModelPreferenceService).clearPreference(USER_ID); + verify(userRecentModelService, never()).recordUsage(any(), anyString()); + } + + // ----- helpers ----- + + private TelegramCommand buildPlainModelCommand() { + Update update = mock(Update.class); + when(update.hasCallbackQuery()).thenReturn(false); + Message message = mock(Message.class); + User from = mock(User.class); + when(from.getId()).thenReturn(USER_ID); + when(message.getFrom()).thenReturn(from); + when(update.getMessage()).thenReturn(message); + + TelegramUser user = buildUser(); + when(telegramUserService.getOrCreateUser(from)).thenReturn(user); + + TelegramCommand command = new TelegramCommand(USER_ID, CHAT_ID, + new TelegramCommandType(TelegramCommand.MODEL), update); + command.languageCode("en"); + return command; + } + + private TelegramUser buildUser() { + TelegramUser user = new TelegramUser(); + user.setId(USER_ID); + user.setLanguageCode("en"); + return user; + } + + private void stubModelFetch(List models) { + when(modelSelectionSession.getOrFetch(eq(USER_ID), any())).thenReturn(models); + } + + private InlineKeyboardMarkup captureSentMarkup() throws TelegramApiException { + ArgumentCaptor captor = ArgumentCaptor.forClass(SendMessage.class); + verify(telegramBot).execute(captor.capture()); + return (InlineKeyboardMarkup) captor.getValue().getReplyMarkup(); + } + + /** + * Nine distinct OpenRouter models so the category menu (not the flat list) + * branch is exercised. + */ + private List buildNineModels() { + return IntStream.range(0, 9) + .mapToObj(i -> new ModelInfo("model-" + i, Set.of(ModelCapabilities.FREE), "OpenRouter")) + .toList(); + } + + // Silence unused mock warning on strict settings. + @SuppressWarnings("unused") + private void unusedGateway(ModelListAIResponse response) { + when(aiGatewayRegistry.getSupportedAiGateways(any())).thenReturn(List.of(aiGateway)); + } +} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java index 9b2f69d3..ee333a9b 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java @@ -2,6 +2,7 @@ import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramSupportedCommandProvider; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,6 +17,7 @@ import java.util.List; import java.util.stream.Stream; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; @@ -39,12 +41,12 @@ class TelegramBotMenuServiceTest { @BeforeEach void setUp() { - when(telegramBotProvider.getObject()).thenReturn(telegramBot); service = new TelegramBotMenuService(telegramBotProvider, commandHandlersProvider); } @Test void setupBotMenu_whenHandlersReturnCommands_thenCallsSetMyCommandsForEachLanguage() throws TelegramApiException { + when(telegramBotProvider.getObject()).thenReturn(telegramBot); TelegramSupportedCommandProvider h1 = lang -> "/start - Start"; TelegramSupportedCommandProvider h2 = lang -> "/role - Set role"; when(commandHandlersProvider.orderedStream()).thenAnswer(inv -> Stream.of(h1, h2)); @@ -62,6 +64,7 @@ void setupBotMenu_whenHandlersReturnCommands_thenCallsSetMyCommandsForEachLangua @Test void setupBotMenu_whenHandlerReturnsCommandWithDescription_thenParsesCorrectly() throws TelegramApiException { + when(telegramBotProvider.getObject()).thenReturn(telegramBot); TelegramSupportedCommandProvider handler = lang -> "/help - Help text"; when(commandHandlersProvider.orderedStream()).thenAnswer(inv -> Stream.of(handler)); @@ -77,6 +80,7 @@ void setupBotMenu_whenHandlerReturnsCommandWithDescription_thenParsesCorrectly() @Test void setupBotMenu_whenTelegramApiException_thenThrowsRuntimeException() throws TelegramApiException { + when(telegramBotProvider.getObject()).thenReturn(telegramBot); TelegramSupportedCommandProvider handler = lang -> "/start - Start"; when(commandHandlersProvider.orderedStream()).thenAnswer((Answer>) inv -> Stream.of(handler)); // Stub any language so that the first setMyCommands call throws (Set iteration order is unspecified) @@ -90,6 +94,7 @@ void setupBotMenu_whenTelegramApiException_thenThrowsRuntimeException() throws T @Test void setupBotMenu_whenNoCommandsForLanguage_thenSkipsAndContinues() throws TelegramApiException { + when(telegramBotProvider.getObject()).thenReturn(telegramBot); TelegramSupportedCommandProvider handler = lang -> null; when(commandHandlersProvider.orderedStream()).thenAnswer((Answer>) inv -> Stream.of(handler)); @@ -97,4 +102,108 @@ void setupBotMenu_whenNoCommandsForLanguage_thenSkipsAndContinues() throws Teleg verify(telegramBot, never()).setMyCommands(anyList(), any(String.class)); } + + // ── Menu version hash / reconcile ──────────────────────────────────── + + @Test + void shouldComputeStableHashAcrossInvocations() { + TelegramSupportedCommandProvider h1 = lang -> "/start - Start"; + TelegramSupportedCommandProvider h2 = lang -> "/role - Set role"; + when(commandHandlersProvider.orderedStream()).thenAnswer(inv -> Stream.of(h1, h2)); + + String first = service.computeCurrentMenuVersionHash(); + String second = service.computeCurrentMenuVersionHash(); + + assertThat(first).isNotBlank().hasSize(64); + assertThat(second).isEqualTo(first); + } + + @Test + void shouldReturnDifferentHashWhenCommandSetChanges() { + TelegramSupportedCommandProvider h1 = lang -> "/start - Start"; + TelegramSupportedCommandProvider h2 = lang -> "/role - Set role"; + TelegramSupportedCommandProvider h3 = lang -> "/mode - Toggle mode"; + when(commandHandlersProvider.orderedStream()) + .thenAnswer(inv -> Stream.of(h1, h2)) + .thenAnswer(inv -> Stream.of(h1, h2)) + .thenAnswer(inv -> Stream.of(h1, h2, h3)) + .thenAnswer(inv -> Stream.of(h1, h2, h3)); + + String before = service.computeCurrentMenuVersionHash(); + String after = service.computeCurrentMenuVersionHash(); + + assertThat(before).isNotEqualTo(after); + } + + @Test + void shouldReconcileWhenHashIsNull() throws TelegramApiException { + when(telegramBotProvider.getObject()).thenReturn(telegramBot); + TelegramSupportedCommandProvider handler = lang -> "/start - Start"; + when(commandHandlersProvider.orderedStream()).thenAnswer(inv -> Stream.of(handler)); + + TelegramUser user = new TelegramUser(); + user.setTelegramId(4242L); + user.setLanguageCode("en"); + user.setMenuVersionHash(null); + + boolean changed = service.reconcileMenuIfStale(user); + + assertThat(changed).isTrue(); + verify(telegramBot).setMyCommands(anyList(), eq(4242L)); + assertThat(user.getMenuVersionHash()).isNotBlank().hasSize(64); + } + + @Test + void shouldReconcileWhenHashDiffers() throws TelegramApiException { + when(telegramBotProvider.getObject()).thenReturn(telegramBot); + TelegramSupportedCommandProvider handler = lang -> "/start - Start"; + when(commandHandlersProvider.orderedStream()).thenAnswer(inv -> Stream.of(handler)); + + TelegramUser user = new TelegramUser(); + user.setTelegramId(4242L); + user.setLanguageCode("en"); + user.setMenuVersionHash("stale-hash-from-an-older-deployment"); + + boolean changed = service.reconcileMenuIfStale(user); + + assertThat(changed).isTrue(); + verify(telegramBot).setMyCommands(anyList(), eq(4242L)); + assertThat(user.getMenuVersionHash()) + .isNotBlank() + .isNotEqualTo("stale-hash-from-an-older-deployment"); + } + + @Test + void shouldSkipReconcileWhenHashMatches() throws TelegramApiException { + TelegramSupportedCommandProvider handler = lang -> "/start - Start"; + when(commandHandlersProvider.orderedStream()).thenAnswer(inv -> Stream.of(handler)); + + String currentHash = service.computeCurrentMenuVersionHash(); + + TelegramUser user = new TelegramUser(); + user.setTelegramId(4242L); + user.setLanguageCode("en"); + user.setMenuVersionHash(currentHash); + + boolean changed = service.reconcileMenuIfStale(user); + + assertThat(changed).isFalse(); + verify(telegramBot, never()).setMyCommands(anyList(), any(Long.class)); + assertThat(user.getMenuVersionHash()).isEqualTo(currentHash); + } + + @Test + void shouldSkipReconcileWhenLanguageCodeIsNull() throws TelegramApiException { + TelegramUser user = new TelegramUser(); + user.setTelegramId(4242L); + user.setLanguageCode(null); + user.setMenuVersionHash(null); + + boolean changed = service.reconcileMenuIfStale(user); + + assertThat(changed).isFalse(); + verify(telegramBot, never()).setMyCommands(anyList(), any(Long.class)); + verify(telegramBot, never()).setMyCommands(anyList(), any(String.class)); + assertThat(user.getMenuVersionHash()).isNull(); + } } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/impl/UserRecentModelServiceImplTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/impl/UserRecentModelServiceImplTest.java new file mode 100644 index 00000000..2d2e742d --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/impl/UserRecentModelServiceImplTest.java @@ -0,0 +1,172 @@ +package io.github.ngirchev.opendaimon.telegram.service.impl; + +import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.common.model.UserRecentModel; +import io.github.ngirchev.opendaimon.common.repository.UserRecentModelRepository; +import io.github.ngirchev.opendaimon.common.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserRecentModelServiceImplTest { + + private static final Long USER_ID = 42L; + + @Mock + private UserRecentModelRepository userRecentModelRepository; + @Mock + private UserRepository userRepository; + + private UserRecentModelServiceImpl service; + + @BeforeEach + void setUp() { + service = new UserRecentModelServiceImpl(userRecentModelRepository, userRepository); + } + + @Test + void shouldInsertWhenAbsent() { + when(userRecentModelRepository.findByUserIdAndModelName(USER_ID, "gpt-4")) + .thenReturn(Optional.empty()); + User userRef = new User(); + userRef.setId(USER_ID); + when(userRepository.getReferenceById(USER_ID)).thenReturn(userRef); + when(userRecentModelRepository.findTopByUser(eq(USER_ID), any(Pageable.class))) + .thenReturn(List.of(entry(1L, "gpt-4", OffsetDateTime.now()))); + + service.recordUsage(USER_ID, "gpt-4"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserRecentModel.class); + verify(userRecentModelRepository).save(captor.capture()); + UserRecentModel saved = captor.getValue(); + assertThat(saved.getModelName()).isEqualTo("gpt-4"); + assertThat(saved.getUser()).isSameAs(userRef); + assertThat(saved.getLastUsedAt()).isNotNull(); + } + + @Test + void shouldUpdateTimestampWhenPresent() { + OffsetDateTime oldTs = OffsetDateTime.now().minusDays(1); + UserRecentModel existing = entry(5L, "claude-opus", oldTs); + when(userRecentModelRepository.findByUserIdAndModelName(USER_ID, "claude-opus")) + .thenReturn(Optional.of(existing)); + when(userRecentModelRepository.findTopByUser(eq(USER_ID), any(Pageable.class))) + .thenReturn(List.of(existing)); + + service.recordUsage(USER_ID, "claude-opus"); + + assertThat(existing.getLastUsedAt()).isAfter(oldTs); + verify(userRecentModelRepository).save(existing); + verify(userRepository, never()).getReferenceById(any()); + } + + @Test + void shouldPruneBeyondEightOnWrite() { + when(userRecentModelRepository.findByUserIdAndModelName(USER_ID, "new-model")) + .thenReturn(Optional.empty()); + User userRef = new User(); + userRef.setId(USER_ID); + when(userRepository.getReferenceById(USER_ID)).thenReturn(userRef); + + List topEight = IntStream.range(0, 8) + .mapToObj(i -> entry((long) (100 + i), "m" + i, OffsetDateTime.now().minusMinutes(i))) + .toList(); + when(userRecentModelRepository.findTopByUser(eq(USER_ID), any(Pageable.class))) + .thenReturn(topEight); + + service.recordUsage(USER_ID, "new-model"); + + ArgumentCaptor> retainCaptor = ArgumentCaptor.forClass(List.class); + verify(userRecentModelRepository).deleteByUserIdAndIdNotIn(eq(USER_ID), retainCaptor.capture()); + assertThat(retainCaptor.getValue()).containsExactly(100L, 101L, 102L, 103L, 104L, 105L, 106L, 107L); + } + + @Test + void shouldReturnEmptyWhenNoHistory() { + when(userRecentModelRepository.findTopByUser(eq(USER_ID), any(Pageable.class))) + .thenReturn(List.of()); + + List result = service.getRecentModels(USER_ID, 8); + + assertThat(result).isEmpty(); + } + + @Test + void shouldReturnRecentModelsOrderedByRepository() { + UserRecentModel first = entry(1L, "alpha", OffsetDateTime.now()); + UserRecentModel second = entry(2L, "beta", OffsetDateTime.now().minusMinutes(5)); + when(userRecentModelRepository.findTopByUser(eq(USER_ID), any(Pageable.class))) + .thenReturn(List.of(first, second)); + + List result = service.getRecentModels(USER_ID, 8); + + assertThat(result).containsExactly("alpha", "beta"); + } + + @Test + void shouldSkipRecordWhenUserIdNull() { + service.recordUsage(null, "gpt-4"); + + verifyNoInteractions(userRecentModelRepository); + verifyNoInteractions(userRepository); + } + + @Test + void shouldSkipRecordWhenModelNameBlank() { + service.recordUsage(USER_ID, " "); + + verifyNoInteractions(userRecentModelRepository); + verifyNoInteractions(userRepository); + } + + @Test + void shouldReturnEmptyWhenLimitNonPositive() { + List result = service.getRecentModels(USER_ID, 0); + + assertThat(result).isEmpty(); + verifyNoInteractions(userRecentModelRepository); + } + + @Test + void shouldNotPruneWhenNoEntriesExist() { + when(userRecentModelRepository.findByUserIdAndModelName(USER_ID, "gpt-4")) + .thenReturn(Optional.empty()); + User userRef = new User(); + userRef.setId(USER_ID); + when(userRepository.getReferenceById(USER_ID)).thenReturn(userRef); + when(userRecentModelRepository.findTopByUser(eq(USER_ID), any(Pageable.class))) + .thenReturn(List.of()); + + service.recordUsage(USER_ID, "gpt-4"); + + verify(userRecentModelRepository, never()).deleteByUserIdAndIdNotIn(any(), anyList()); + } + + private UserRecentModel entry(Long id, String name, OffsetDateTime ts) { + UserRecentModel entry = new UserRecentModel(); + entry.setId(id); + entry.setModelName(name); + entry.setLastUsedAt(ts); + return entry; + } +} From cbb51106eeda93346ac65bdda593d50d66726318 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Fri, 24 Apr 2026 08:27:51 +0300 Subject: [PATCH 09/14] Stage 1 --- .claude/rules/java/telegram-module.md | 14 ++ docs/plan/telegram-group-entity-plan.md | 214 ++++++++++++++++++ opendaimon-telegram/TELEGRAM_MODULE.md | 12 + .../telegram/model/TelegramGroup.java | 53 +++++ .../repository/TelegramGroupRepository.java | 15 ++ .../V3__Create_telegram_group_table.sql | 16 ++ 6 files changed, 324 insertions(+) create mode 100644 docs/plan/telegram-group-entity-plan.md create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/model/TelegramGroup.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/repository/TelegramGroupRepository.java create mode 100644 opendaimon-telegram/src/main/resources/db/migration/telegram/V3__Create_telegram_group_table.sql diff --git a/.claude/rules/java/telegram-module.md b/.claude/rules/java/telegram-module.md index 06da7f5f..1b5d74eb 100644 --- a/.claude/rules/java/telegram-module.md +++ b/.claude/rules/java/telegram-module.md @@ -5,3 +5,17 @@ paths: # Telegram Module Before modifying Telegram module behavior, read `opendaimon-telegram/TELEGRAM_MODULE.md`. + +## Group Chat Conceptual Model + +In this project a **group chat is treated as a single logical participant**, not as a set of individuals. All state that Telegram scopes per-chat — conversation history, current model, current language for the bot menu, command menu snapshot — is attached to `chat_id`, and every participant of the group shares it. There is no per-user-inside-group isolation. + +Practical consequences — apply these every time you touch Telegram code: + +1. **Scope key is always `chat_id`, never `user.telegramId`.** In private chats they happen to be equal (Telegram uses the user id as the chat id), in groups they diverge (group `chat_id` is negative, e.g. `-1001234567890`). Code that keys on `user.telegramId` works in private but silently breaks in groups. +2. **Per-chat Telegram state** (e.g. in-memory cache of which chats we already synced the command menu to) must be keyed on `chat_id` — typically the value returned by `update.getMessage().getChatId()` or `command.telegramId()` (whose field name is misleading — it stores the chat id, see `TelegramCommand.java`). +3. **`/language` and `/model` in a group are last-writer-wins.** Whoever invokes the command sets it for the entire group; the previous setting is overwritten. This is intentional and matches the "one shared chat" model — do not introduce per-user-inside-group logic. +4. **`BotCommandScopeChat`** with the group `chat_id` overrides Default scope for the whole group. `BotCommandScopeChatMember` (per-user-in-chat) is NOT used — it contradicts the shared-chat model. +5. **Routing filter** (group/supergroup → process only `/cmd@bot`, reply-to-bot, or explicit self-mention) is separate from this model: it decides *whether* to process, not *whom* the state belongs to. See "Group/Supergroup Routing Policy" in `TELEGRAM_MODULE.md`. + +If a change appears to require per-user-inside-group state (e.g. "each member gets their own history in the group"), stop — that is a different product decision and must be discussed with the user before implementation. diff --git a/docs/plan/telegram-group-entity-plan.md b/docs/plan/telegram-group-entity-plan.md new file mode 100644 index 00000000..b3ea545d --- /dev/null +++ b/docs/plan/telegram-group-entity-plan.md @@ -0,0 +1,214 @@ +# Handoff: TelegramGroup entity + groups menu fix + +This document is a self-contained handoff prompt for a fresh session / agent. +It captures context that is NOT yet encoded in the codebase so the next iteration +can continue without re-discovering it. + +## TL;DR for the next session + +The user needs a `TelegramGroup` entity that mirrors `TelegramUser` in composition +— fields, auto-timestamps, derived sub-tables (messages, sessions, recent models, +thread scope, menu bookkeeping) — so that **group chats are treated as a single +logical participant** with their own persistent settings and their own conversation +summary, independent of which individual user invoked any particular command. + +Until this entity exists, one previously-shipped fix (lazy per-chat Telegram menu +reconciliation) is **partially broken**: it works in private chats but silently +misses groups. The full plan is blocked on `TelegramGroup` landing first. + +## Project conceptual model (authoritative — do NOT re-derive) + +In this project a Telegram **group** (or supergroup) is conceptually a **single +logical participant**, not a collection of individual members. All chat-scoped +state — conversation history/summary, current model preference, language of the +command menu, per-chat bot command menu snapshot — belongs to the group as a whole +and is shared by every member. + +- There is no per-user-inside-group isolation. Do not introduce it. +- Settings set by member A are visible to member B in the same group. That is + intentional. +- The **scope key for every Telegram API call** (setMyCommands scope, history + scope, model preference) is always `chat_id`, never `user.telegramId`. They + coincide in private chats and diverge in groups (group `chat_id` is negative). + +Already documented in `opendaimon-telegram/TELEGRAM_MODULE.md` → +"Group Chat Conceptual Model" and in `.claude/rules/java/telegram-module.md`. +**Note:** an earlier edit of those two files framed groups as "last-writer-wins on +user-level settings". That framing is wrong for this project — the user clarified +that settings belong to the group itself, not to whichever user wrote last. Both +files must be corrected to reflect the authoritative model above when this work +is picked up. + +## Current state of the code (what exists today) + +Entities (`opendaimon-telegram/src/main/java/.../model/`): +- `TelegramUser extends User` — `@DiscriminatorValue("TELEGRAM")`. Fields include + `telegramId` (the user's personal Telegram id, unique, not null), plus inherited + `languageCode`, `preferredModelId`, `agentModeEnabled`, `thinkingMode`, + `isAdmin`, `isPremium`, `isBlocked`, timestamps, `menuVersionHash` (added in V2 + telegram migration, see below). +- `TelegramUserSession` — transient per-user FSM state. +- `TelegramWhitelist` — access control rows. +- No `TelegramGroup` / `TelegramChat` / `TelegramChatSettings` entity exists. + +Settings persistence keyed on **user** (not chat): +- `/language` in `LanguageTelegramCommandHandler.java:115` calls + `telegramUserService.updateLanguageCode(cq.getFrom().getId(), normalized)` — + writes to the invoker's `TelegramUser` row. +- `/model` via `UserModelPreferenceService.setPreferredModel(userId, modelName)` + — writes to the invoker's `TelegramUser`. +- In a group chat these writes go to the **invoker**'s row, not to any group-level + row. That means group members each have their own copy of language/model, and + the bot currently reads the invoker's. This is the bug the `TelegramGroup` + entity is meant to fix. + +Conversation history IS already keyed on `chat_id`: +- `ConversationThread` (in `opendaimon-common`) has a `scopeKind` enum + (`ThreadScopeKind.USER` or `ThreadScopeKind.TELEGRAM_CHAT`) and `scope_id` + holds `chat_id` when `scopeKind=TELEGRAM_CHAT`. +- Telegram handlers (`ThreadsTelegramCommandHandler`, `NewThreadTelegramCommandHandler`, + `ModelTelegramCommandHandler`, `HistoryTelegramCommandHandler`, + `TelegramMessageService`) all use `ThreadScopeKind.TELEGRAM_CHAT` with the + chat id (via `command.telegramId()` — note the misleading field name, it holds + `chat_id`, see `TelegramCommand.java` constructors `this.telegramId = chatId`). +- So conversation summary / memory is already per-chat. Good — nothing to move + there. What's missing is a **persistent settings row** for the chat. + +## What was shipped recently (migration V2, telegram module) + +Migration `opendaimon-telegram/src/main/resources/db/migration/telegram/V2__Add_menu_version_hash_to_telegram_user.sql`: + +```sql +ALTER TABLE telegram_user + ADD COLUMN IF NOT EXISTS menu_version_hash VARCHAR(64); +``` + +Code changes (committed to working tree, NOT yet to git): +- `TelegramUser.menuVersionHash` field (nullable). +- `TelegramBotMenuService.computeCurrentMenuVersionHash()` — SHA-256 of the + sorted (language, command description) pairs; lazy double-checked-locking cache. +- `TelegramBotMenuService.reconcileMenuIfStale(TelegramUser user)` — if + `user.languageCode != null` and `user.menuVersionHash != currentHash` → call + `setupBotMenuForUser(user.getTelegramId(), user.getLanguageCode())` + stamp + new hash on the entity. Caller persists. +- `TelegramBot.reconcileMenuIfStale(TelegramUser)` (private helper) is invoked + from `mapToTelegramTextCommand` (slash-command branch) and `mapToTelegramCommand` + (callback). Swallows exceptions. +- `TelegramUserService.updateMenuVersionHash(Long telegramId, String hash)` — + persistence helper. +- Unit tests in `TelegramBotMenuServiceTest` and `TelegramBotTest`. + +**This migration V2 has already been applied to at least one production database. +It is immutable** — any future change to column shape, type, or content must go +through a new migration (V3+). Do NOT edit V2. + +## The gap (what is NOT done and why) + +The menu reconcile was designed around `TelegramUser.menuVersionHash`. In a group +chat the Telegram menu scope Telegram enforces is keyed on the **group chat id**, +not on any individual user id. So: + +- In a private chat, `user.telegramId == chat_id` → reconcile correctly pushes + the menu snapshot to the right scope. Works. +- In a group chat, `user.telegramId != chat_id`. `reconcileMenuIfStale` uses + `user.getTelegramId()` as the scope key → it pushes the menu to the **invoker's + private chat scope**, not to the group. The group's menu snapshot remains stale + forever. Symptom: new commands (`/mode`, `/thinking`, etc.) never appear in the + group's `/` picker even after the user selects a new model or starts a new + thread. + +We cannot cleanly fix this without a `TelegramGroup` entity, because: + +1. There is no place to store the group's own `menuVersionHash` (hash is per-chat; + a group has many users but one menu). +2. There is no place to store the group's own `languageCode` or `preferredModelId`, + so we cannot consistently pick "the language to push for this group". +3. Any in-memory-only workaround (e.g. `ConcurrentHashMap syncedChats`) + loses state on restart and does not address (2) — the invoker's language would + still leak into group-level decisions. + +## What the user asks for next + +Create `TelegramGroup` that is **identical in composition to `TelegramUser`** — +same fields (language, preferred model, flags, timestamps), same derived sub-tables +where applicable (recent models, whitelist-like toggles), same kind of auto-timestamp +lifecycle. The group stores its own settings, independent of any member. + +The user explicitly noted: "чтобы так же вести саммари" — "so that summaries work +the same way". That means: whatever per-user conversation summary mechanism +already exists (via `ConversationThread` with `ThreadScopeKind.TELEGRAM_CHAT`, +`memoryBullets`, summarization logic) must transparently work against the group +entity. Since thread scope is already chat-id-based, the summary path is probably +already correct once the entity exists — verify, don't duplicate. + +## Scope of the next piece of work + +1. Design and land `TelegramGroup` entity: + - Does it extend `User` with a second `@DiscriminatorValue` (e.g. `TELEGRAM_GROUP`)? + JOINED inheritance is already in place on `User` — this is the low-friction path. + - Or does it live as a standalone entity (`telegram_group` table with its own + primary key, independent of `user`)? This is cleaner domain-wise but loses + polymorphic benefits for code that already takes `User`. + - Decide with the user before implementing. Both options have downstream + consequences for repositories, services, and every handler that takes + `TelegramUser`. +2. Introduce a way to **resolve the settings owner for the current update**: a + single helper like `chatSettingsOwner(update) → TelegramUser | TelegramGroup` + that returns the user entity for private chats and the group entity for groups. + All handlers (`LanguageTelegramCommandHandler`, `ModeTelegramCommandHandler`, + `ModelTelegramCommandHandler`, `ThinkingTelegramCommandHandler`, etc.) must + use this resolver instead of the invoker's `TelegramUser` when reading/writing + `languageCode`, `preferredModelId`, `agentModeEnabled`, `thinkingMode`, + `menuVersionHash`. +3. **Redo the menu reconcile**: it must now key on the settings owner, which is + the group in groups and the user in privates. The menu scope for the Telegram + API call is the chat id (same rule as before). The hash lives on whichever + entity is the settings owner. +4. Correct `opendaimon-telegram/TELEGRAM_MODULE.md` and + `.claude/rules/java/telegram-module.md`: remove the "last-writer-wins" framing + and replace with "the group owns the settings row; all members read/write + it as one". The files already carry the core model — just fix the tone of + the sentence about `/language` and `/model`. +5. Tests: every handler that currently has a unit test against `TelegramUser` + should grow a sibling test against `TelegramGroup` via the resolver. The + existing `TelegramBotMenuServiceTest` reconcile cases must be re-parameterised + over owner entity. + +## Pitfalls to flag to the user before implementing + +- `User` is a JOINED-inheritance root in `opendaimon-common`. A second discriminator + means a new child table (`telegram_group`) joined back to `user` for shared + columns. This is a significant schema addition — needs a core-module migration + (V16+). +- Existing code paths that call `telegramUserRepository.findByTelegramId(...)` + assume uniqueness of `telegram_id` within the `telegram_user` child table. + For a group, `telegram_id` would be the chat id (negative). That's fine per + Telegram (chat ids and user ids don't collide), but the column is currently + only on the `telegram_user` child table — the group equivalent needs its own + `telegram_id` column on `telegram_group` or needs to reuse the same. Think this + through with the user. +- Every place that does `message.getFrom()` for user identity must NOT be + changed to return the group — `getFrom()` is correctly the human member. + Only the **settings owner resolution** changes. +- The user noted a past production-facing incident around modifying an applied + Flyway migration. Do NOT edit `V2__Add_menu_version_hash_to_telegram_user.sql` + or any migration that has `checksum` rows in `flyway_schema_history_*`. + New work is always a new version. + +## Where to start reading in the codebase + +- `opendaimon-telegram/TELEGRAM_MODULE.md` — module-level behavior reference. + Read the "Group Chat Conceptual Model" section for the authoritative intent. +- `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/model/TelegramUser.java` + — the entity to mirror. +- `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramUserService.java` + — the service pattern to mirror for groups. +- `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java` + — the `mapToTelegram*Command` methods are the integration points that need + to route through the settings-owner resolver. +- `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandler.java` + — a clean example of a handler that currently writes settings to the invoker + and must be migrated to write to the settings owner. +- `opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/model/ConversationThread.java` + + `ThreadScopeKind.java` — the model that already does per-chat scoping for + history; study this pattern before designing `TelegramGroup`. diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index a2e84bca..eb3ab0b7 100644 --- a/opendaimon-telegram/TELEGRAM_MODULE.md +++ b/opendaimon-telegram/TELEGRAM_MODULE.md @@ -43,6 +43,18 @@ onUpdateReceived(Update) | message/caption contains explicit self mention | processed | | any other group message | skipped (no command dispatch, no AI call) | +### Group Chat Conceptual Model + +A group (or supergroup) is treated as a **single logical participant**, not as a set of individuals. All chat-scoped state — conversation history, current model preference, language selected for the command menu, per-chat menu snapshot — is attached to the `chat_id` and **shared by every member** of the group. There is no per-user-inside-group isolation. + +Implications: + +- The **scope key for Telegram API calls is always `chat_id`**, never `user.telegramId`. In a private chat the two values coincide because Telegram uses the user id as the chat id; in a group they diverge (group `chat_id` is negative, e.g. `-1001234567890`). +- `TelegramCommand` has a field named `telegramId`, but it actually stores the **chat id** (see its constructors: `this.telegramId = chatId`). The name is historical and misleading — treat it as `chatId` when reasoning about scope. +- `/language` and `/model` invoked in a group are **last-writer-wins** for the group. The most recent invoker sets the value for everyone in that group. This is intentional — do not add per-user-inside-group state. +- `BotCommandScopeChat(chat_id)` with the group id overrides Default scope for the group. `BotCommandScopeChatMember` (per-user-in-chat) is deliberately unused; it would contradict the shared-chat model. +- Per-chat runtime caches (e.g. an in-memory "which chats we already pushed the current command menu to") must be keyed on `chat_id`, not `user.telegramId`, otherwise they silently miss groups. + ### Inline Query Policy | Condition | Result | |-----------|--------| diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/model/TelegramGroup.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/model/TelegramGroup.java new file mode 100644 index 00000000..ae60a6fd --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/model/TelegramGroup.java @@ -0,0 +1,53 @@ +package io.github.ngirchev.opendaimon.telegram.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import io.github.ngirchev.opendaimon.common.model.User; + +/** + * Telegram group or supergroup represented as a single logical participant. + * All chat-scoped settings (language, preferred model, agent mode, thinking mode, + * assistant role, recent models) live on this row and are shared by every member. + *

+ * {@code telegramId} stores the Telegram {@code chat_id} (negative for groups). + * Parallel to {@link TelegramUser#telegramId}; positive/negative value space + * prevents cross-subtype collisions in practice. + */ +@Entity +@Table(name = "telegram_group") +@DiscriminatorValue("TELEGRAM_GROUP") +@Getter +@Setter +@ToString +@NoArgsConstructor +public class TelegramGroup extends User { + + @Column(name = "telegram_id", unique = true, nullable = false) + private Long telegramId; + + @Column(name = "title", length = 512) + private String title; + + /** + * Telegram chat type as reported by the API: {@code "group"} or {@code "supergroup"}. + */ + @Column(name = "type", length = 32) + private String type; + + /** + * SHA-256 hex of the command set last pushed to Telegram for this group via + * {@code BotCommandScopeChat}. Null when no chat-scoped menu has ever been set. + *

+ * See {@code TelegramBotMenuService#reconcileMenuIfStale} for the update path. + */ + @Column(name = "menu_version_hash", length = 64) + private String menuVersionHash; + + @Override + public Long getId() { + return super.getId(); + } +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/repository/TelegramGroupRepository.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/repository/TelegramGroupRepository.java new file mode 100644 index 00000000..857e9575 --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/repository/TelegramGroupRepository.java @@ -0,0 +1,15 @@ +package io.github.ngirchev.opendaimon.telegram.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import io.github.ngirchev.opendaimon.telegram.model.TelegramGroup; + +import java.util.Optional; + +@Repository +public interface TelegramGroupRepository extends JpaRepository { + + Optional findByTelegramId(Long telegramId); + + boolean existsByTelegramId(Long telegramId); +} diff --git a/opendaimon-telegram/src/main/resources/db/migration/telegram/V3__Create_telegram_group_table.sql b/opendaimon-telegram/src/main/resources/db/migration/telegram/V3__Create_telegram_group_table.sql new file mode 100644 index 00000000..1a4041e1 --- /dev/null +++ b/opendaimon-telegram/src/main/resources/db/migration/telegram/V3__Create_telegram_group_table.sql @@ -0,0 +1,16 @@ +-- TelegramGroup child table joined to "user" via JOINED inheritance (discriminator: TELEGRAM_GROUP). +-- Represents a Telegram group or supergroup as a single logical participant: settings +-- (language, preferred model, agent mode, thinking mode, assistant role, menu version hash, +-- recent models) belong to the group row, shared by every member. +-- +-- telegram_id holds the Telegram chat_id (negative for groups/supergroups). Parallel to +-- telegram_user.telegram_id; positive-vs-negative value space prevents collisions in practice. +CREATE TABLE IF NOT EXISTS telegram_group ( + id BIGINT PRIMARY KEY REFERENCES "user"(id), + telegram_id BIGINT UNIQUE NOT NULL, + title VARCHAR(512), + type VARCHAR(32), + menu_version_hash VARCHAR(64) +); + +CREATE INDEX IF NOT EXISTS idx_telegram_group_telegram_id ON telegram_group(telegram_id); From 012dd97f6ce7adeea9885fc533f6637af5bd205f Mon Sep 17 00:00:00 2001 From: ngirchev Date: Sat, 25 Apr 2026 09:33:08 +0300 Subject: [PATCH 10/14] Stage 8 --- .claude/rules/java/telegram-module.md | 15 +- docs/plan/telegram-group-entity-plan.md | 9 + .../fixture/config/TelegramFixtureConfig.java | 53 ++- .../it/telegram/TelegramGroupEntityIT.java | 345 ++++++++++++++++++ .../it/telegram/TelegramMockGatewayIT.java | 53 ++- .../MessageTelegramCommandHandlerIT.java | 53 ++- .../repository/TelegramGroupRepositoryIT.java | 172 +++++++++ .../common/config/CoreAutoConfig.java | 12 +- .../common/service/ChatOwnerLookup.java | 24 ++ .../common/service/SummarizationService.java | 29 +- .../service/SummarizationServiceTest.java | 67 +++- opendaimon-telegram/TELEGRAM_MODULE.md | 40 +- .../opendaimon/telegram/TelegramBot.java | 108 ++++-- .../telegram/command/TelegramCommand.java | 31 ++ .../impl/LanguageTelegramCommandHandler.java | 13 +- .../impl/MessageTelegramCommandHandler.java | 12 +- .../impl/ModeTelegramCommandHandler.java | 15 +- .../impl/ModelTelegramCommandHandler.java | 66 ++-- .../impl/NewThreadTelegramCommandHandler.java | 17 +- .../impl/RoleTelegramCommandHandler.java | 23 +- .../impl/ThinkingTelegramCommandHandler.java | 17 +- .../fsm/TelegramMessageHandlerActions.java | 48 ++- .../telegram/config/TelegramAutoConfig.java | 6 +- .../config/TelegramCommandHandlerConfig.java | 39 +- .../config/TelegramServiceConfig.java | 47 ++- .../service/ChatSettingsOwnerResolver.java | 65 ++++ .../telegram/service/ChatSettingsService.java | 140 +++++++ .../service/PersistentKeyboardService.java | 22 +- .../service/TelegramAgentStreamRenderer.java | 9 +- .../service/TelegramBotMenuService.java | 61 +++- .../service/TelegramChatOwnerLookup.java | 24 ++ .../service/TelegramGroupService.java | 165 +++++++++ .../service/TelegramMessageService.java | 39 +- .../opendaimon/telegram/TelegramBotTest.java | 26 +- ...elegramTextCommandHandlerProviderTest.java | 12 + .../LanguageTelegramCommandHandlerTest.java | 12 +- .../MessageTelegramCommandHandlerTest.java | 6 +- .../impl/ModeTelegramCommandHandlerTest.java | 9 +- .../impl/ModelTelegramCommandHandlerTest.java | 10 +- .../impl/RoleTelegramCommandHandlerTest.java | 12 +- .../ThinkingTelegramCommandHandlerTest.java | 10 +- ...elegramMessageHandlerActionsAgentTest.java | 6 +- ...ramMessageHandlerActionsStreamingTest.java | 6 +- ...essageHandlerActionsTentativeEditTest.java | 6 +- .../ChatSettingsOwnerResolverTest.java | 121 ++++++ .../service/ChatSettingsServiceTest.java | 198 ++++++++++ .../PersistentKeyboardServiceTest.java | 13 +- .../service/TelegramBotMenuServiceTest.java | 12 +- .../service/TelegramGroupServiceTest.java | 177 +++++++++ .../service/TelegramMessageServiceTest.java | 18 +- 50 files changed, 2221 insertions(+), 272 deletions(-) create mode 100644 opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java create mode 100644 opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/repository/TelegramGroupRepositoryIT.java create mode 100644 opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/service/ChatOwnerLookup.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsOwnerResolver.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsService.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatOwnerLookup.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsOwnerResolverTest.java create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsServiceTest.java create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java diff --git a/.claude/rules/java/telegram-module.md b/.claude/rules/java/telegram-module.md index 1b5d74eb..374ceeed 100644 --- a/.claude/rules/java/telegram-module.md +++ b/.claude/rules/java/telegram-module.md @@ -8,14 +8,17 @@ Before modifying Telegram module behavior, read `opendaimon-telegram/TELEGRAM_MO ## Group Chat Conceptual Model -In this project a **group chat is treated as a single logical participant**, not as a set of individuals. All state that Telegram scopes per-chat — conversation history, current model, current language for the bot menu, command menu snapshot — is attached to `chat_id`, and every participant of the group shares it. There is no per-user-inside-group isolation. +In this project a **group chat is treated as a single logical participant**, not as a set of individuals. All state that Telegram scopes per-chat — conversation history, current model, current language for the bot menu, command menu snapshot, assistant role, agent mode, thinking mode, recent models — belongs to a dedicated `TelegramGroup` entity (JOINED-inheritance subclass of `User`, discriminator `TELEGRAM_GROUP`) and every participant of the group shares it. There is no per-user-inside-group isolation. Practical consequences — apply these every time you touch Telegram code: 1. **Scope key is always `chat_id`, never `user.telegramId`.** In private chats they happen to be equal (Telegram uses the user id as the chat id), in groups they diverge (group `chat_id` is negative, e.g. `-1001234567890`). Code that keys on `user.telegramId` works in private but silently breaks in groups. -2. **Per-chat Telegram state** (e.g. in-memory cache of which chats we already synced the command menu to) must be keyed on `chat_id` — typically the value returned by `update.getMessage().getChatId()` or `command.telegramId()` (whose field name is misleading — it stores the chat id, see `TelegramCommand.java`). -3. **`/language` and `/model` in a group are last-writer-wins.** Whoever invokes the command sets it for the entire group; the previous setting is overwritten. This is intentional and matches the "one shared chat" model — do not introduce per-user-inside-group logic. -4. **`BotCommandScopeChat`** with the group `chat_id` overrides Default scope for the whole group. `BotCommandScopeChatMember` (per-user-in-chat) is NOT used — it contradicts the shared-chat model. -5. **Routing filter** (group/supergroup → process only `/cmd@bot`, reply-to-bot, or explicit self-mention) is separate from this model: it decides *whether* to process, not *whom* the state belongs to. See "Group/Supergroup Routing Policy" in `TELEGRAM_MODULE.md`. +2. **Per-chat Telegram state** (in-memory cache of which chats we already synced the command menu to, etc.) must be keyed on `chat_id` — typically the value returned by `update.getMessage().getChatId()` or `command.telegramId()` (whose field name is misleading — it stores the chat id, see `TelegramCommand.java`). +3. **Settings belong to the chat entity, not the invoker.** `/language`, `/model`, `/mode`, `/thinking`, `/role` all write to the resolved *settings owner* — a `TelegramGroup` row in group chats, the invoker's `TelegramUser` row in private chats. Resolution happens once per update in `TelegramBot.mapToTelegram*` via `ChatSettingsOwnerResolver.resolveForChat(chat, invoker)`; the result is stamped on `TelegramCommand.settingsOwner` and consumed by handlers through `ChatSettingsService`. Do NOT key settings writes on `cq.getFrom().getId()` or `user.telegramId` — that reintroduces per-invoker leakage (the original Bug #114 pattern). +4. **Adding a new chat-scoped setting?** Add the field to `User` (inherited by both `TelegramUser` and `TelegramGroup`) and route reads/writes through `ChatSettingsService` over a `User owner`. Never introduce a path that reads/writes the field only on `TelegramUser`. +5. **`BotCommandScopeChat`** with the group `chat_id` overrides Default scope for the whole group. `BotCommandScopeChatMember` (per-user-in-chat) is NOT used — it contradicts the shared-chat model. Menu version hash lives on whichever owner resolved for the chat; `TelegramBotMenuService.reconcileMenuIfStale(User owner, Long chatId)` dispatches hash read/write by subtype and persists via `ChatSettingsService`. +6. **Routing filter** (group/supergroup → process only `/cmd@bot`, reply-to-bot, or explicit self-mention) is separate from this model: it decides *whether* to process, not *whom* the state belongs to. See "Group/Supergroup Routing Policy" in `TELEGRAM_MODULE.md`. +7. **Exceptions to the "group = single participant" rule:** the FSM `TelegramUserSession.botStatus` (pending-input state, e.g. "awaiting custom role text") stays per-invoker so one member's `/role custom` flow does not eat another member's text. Whitelist / priority (admin/vip/regular) is also per-invoker — groups have no access level; their members do. +8. **Cross-module summarization lookup:** `SummarizationService` (in `opendaimon-common`) resolves the chat-scoped preferredModelId via `ChatOwnerLookup.findByChatId(thread.scopeId)` — a common-module SPI bound by `TelegramChatOwnerLookup` in the telegram module. This guarantees the group's picked model lands in `ChatAICommand.metadata` and prevents the HTTP 400 "model is required" regression. -If a change appears to require per-user-inside-group state (e.g. "each member gets their own history in the group"), stop — that is a different product decision and must be discussed with the user before implementation. +If a change appears to require per-user-inside-group state for any other field (e.g. "each member gets their own history in the group"), stop — that is a different product decision and must be discussed with the user before implementation. diff --git a/docs/plan/telegram-group-entity-plan.md b/docs/plan/telegram-group-entity-plan.md index b3ea545d..e1b44c22 100644 --- a/docs/plan/telegram-group-entity-plan.md +++ b/docs/plan/telegram-group-entity-plan.md @@ -1,3 +1,12 @@ +> **SUPERSEDED** — The work described below was implemented in Stages 1–6 of the +> TelegramGroup migration. The authoritative behavior reference is now +> `opendaimon-telegram/TELEGRAM_MODULE.md` § *Group Chat Conceptual Model* +> (section "Settings Owner Resolution"). The rule file +> `.claude/rules/java/telegram-module.md` was also corrected — its earlier +> "last-writer-wins" framing was wrong and has been replaced with the +> settings-owner model. Keep this plan doc for historical context only; +> do not use it as a spec. + # Handoff: TelegramGroup entity + groups menu fix This document is a self-contained handoff prompt for a fresh session / agent. diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java index cadad27e..053a6d66 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java @@ -46,7 +46,13 @@ import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; -import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup; +import io.github.ngirchev.opendaimon.common.repository.UserRepository; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsOwnerResolver; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatOwnerLookup; +import io.github.ngirchev.opendaimon.telegram.service.TelegramGroupService; +import io.github.ngirchev.opendaimon.telegram.repository.TelegramGroupRepository; import io.github.ngirchev.opendaimon.common.storage.service.FileStorageService; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @@ -242,7 +248,9 @@ public TelegramMessageService telegramMessageService( MessageLocalizationService messageLocalizationService, ObjectProvider storagePropertiesProvider, ConversationThreadService conversationThreadService, - ObjectProvider telegramMessageServiceSelfProvider) { + ObjectProvider telegramMessageServiceSelfProvider, + ChatOwnerLookup chatOwnerLookup, + ChatSettingsService chatSettingsService) { return new TelegramMessageService( messageService, telegramUserService, @@ -250,7 +258,9 @@ public TelegramMessageService telegramMessageService( messageLocalizationService, storagePropertiesProvider, conversationThreadService, - telegramMessageServiceSelfProvider); + telegramMessageServiceSelfProvider, + chatOwnerLookup, + chatSettingsService); } @Bean @@ -278,22 +288,41 @@ public RecordingTelegramBot telegramBot( } @Bean - public UserModelPreferenceService userModelPreferenceService( - TelegramUserRepository telegramUserRepository) { - return new UserModelPreferenceService(telegramUserRepository); + public TelegramGroupService telegramGroupService( + TelegramGroupRepository telegramGroupRepository, + io.github.ngirchev.opendaimon.common.service.AssistantRoleService assistantRoleService) { + return new TelegramGroupService(telegramGroupRepository, assistantRoleService, false); + } + + @Bean + public ChatSettingsService chatSettingsService( + TelegramUserService telegramUserService, + TelegramGroupService telegramGroupService) { + return new ChatSettingsService(telegramUserService, telegramGroupService); + } + + @Bean + public ChatSettingsOwnerResolver chatSettingsOwnerResolver( + TelegramUserService telegramUserService, + TelegramGroupService telegramGroupService) { + return new ChatSettingsOwnerResolver(telegramUserService, telegramGroupService); + } + + @Bean + public ChatOwnerLookup chatOwnerLookup(ChatSettingsOwnerResolver resolver) { + return new TelegramChatOwnerLookup(resolver); } @Bean public PersistentKeyboardService persistentKeyboardService( - UserModelPreferenceService userModelPreferenceService, CoreCommonProperties coreCommonProperties, ObjectProvider telegramBotProvider, TelegramProperties telegramProperties, MessageLocalizationService messageLocalizationService, - TelegramUserRepository telegramUserRepository) { + UserRepository userRepository) { return new PersistentKeyboardService( - userModelPreferenceService, coreCommonProperties, telegramBotProvider, - telegramProperties, messageLocalizationService, telegramUserRepository); + coreCommonProperties, telegramBotProvider, + telegramProperties, messageLocalizationService, userRepository); } @Bean @@ -317,7 +346,7 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( OpenDaimonMessageService messageService, AIRequestPipeline aiRequestPipeline, TelegramProperties telegramProperties, - UserModelPreferenceService userModelPreferenceService, + ChatSettingsService chatSettingsService, PersistentKeyboardService persistentKeyboardService, ReplyImageAttachmentService replyImageAttachmentService) { TelegramMessageSender messageSender = new TelegramMessageSender( @@ -325,7 +354,7 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( TelegramMessageHandlerActions actions = new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, - userModelPreferenceService, persistentKeyboardService, replyImageAttachmentService, + chatSettingsService, persistentKeyboardService, replyImageAttachmentService, messageSender, null, null, 10, false); ExDomainFsm handlerFsm = MessageHandlerFsmFactory.create(actions); diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java new file mode 100644 index 00000000..c686c034 --- /dev/null +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java @@ -0,0 +1,345 @@ +package io.github.ngirchev.opendaimon.it.telegram; + +import io.github.ngirchev.opendaimon.common.config.CoreCommonProperties; +import io.github.ngirchev.opendaimon.common.config.CoreFlywayConfig; +import io.github.ngirchev.opendaimon.common.config.CoreJpaConfig; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; +import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.common.model.UserRecentModel; +import io.github.ngirchev.opendaimon.common.repository.UserRecentModelRepository; +import io.github.ngirchev.opendaimon.common.repository.UserRepository; +import io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup; +import io.github.ngirchev.opendaimon.it.ITTestConfiguration; +import io.github.ngirchev.opendaimon.it.fixture.config.TelegramFixtureConfig; +import io.github.ngirchev.opendaimon.telegram.config.TelegramFlywayConfig; +import io.github.ngirchev.opendaimon.telegram.config.TelegramJpaConfig; +import io.github.ngirchev.opendaimon.telegram.model.TelegramGroup; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.repository.TelegramGroupRepository; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsOwnerResolver; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; +import io.github.ngirchev.opendaimon.test.AbstractContainerIT; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.telegram.telegrambots.meta.api.objects.Chat; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end integration test for the TelegramGroup settings-owner model against a + * real Postgres (Testcontainers) and a full Spring context. + *

+ * Verifies the three properties the group migration is supposed to guarantee: + *

    + *
  1. First interaction with an unseen group chat creates a {@link TelegramGroup} + * row lazily via {@link ChatSettingsOwnerResolver#resolveForChat}.
  2. + *
  3. {@link ChatSettingsService} writes route to the group row — different invokers + * see the same settings in subsequent reads (no per-invoker leakage, Bug #114).
  4. + *
  5. {@link ChatOwnerLookup} (SPI bound to {@code TelegramChatOwnerLookup}) finds the + * group by {@code chat_id} — the path summarization uses to seed preferredModelId.
  6. + *
+ */ +@SpringBootTest( + classes = ITTestConfiguration.class, + properties = { + "spring.main.banner-mode=off", + "spring.autoconfigure.exclude=" + + "io.github.ngirchev.opendaimon.common.config.CoreAutoConfig," + + "io.github.ngirchev.opendaimon.bulkhead.config.BulkHeadAutoConfig," + + "io.github.ngirchev.opendaimon.telegram.config.TelegramAutoConfig," + + "org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration," + + "org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration," + + "org.springframework.ai.model.openai.autoconfigure.OpenAiAudioSpeechAutoConfiguration," + + "org.springframework.ai.model.openai.autoconfigure.OpenAiAudioTranscriptionAutoConfiguration," + + "org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration," + + "org.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfiguration," + + "org.springframework.ai.model.openai.autoconfigure.OpenAiModerationAutoConfiguration" + } +) +@ActiveProfiles("integration-test") +@EnableConfigurationProperties(CoreCommonProperties.class) +@Import({ + CoreFlywayConfig.class, + CoreJpaConfig.class, + TelegramFlywayConfig.class, + TelegramJpaConfig.class, + TelegramFixtureConfig.class +}) +class TelegramGroupEntityIT extends AbstractContainerIT { + + private static final long GROUP_CHAT_ID = -1007654321098L; + private static final long MEMBER_ALICE_ID = 90001L; + private static final long MEMBER_BOB_ID = 90002L; + + @Autowired + private ChatSettingsOwnerResolver resolver; + + @Autowired + private ChatSettingsService chatSettingsService; + + @Autowired + private ChatOwnerLookup chatOwnerLookup; + + @Autowired + private TelegramGroupRepository telegramGroupRepository; + + @Autowired + private UserRecentModelRepository userRecentModelRepository; + + @Autowired + private UserRepository userRepository; + + @Test + @Transactional + @DisplayName("recent-models are chat-scoped: invoker's private recents DO NOT leak into the group view") + void shouldScopeRecentModelsToChatEntityNotInvoker() { + long chatId = GROUP_CHAT_ID - 7; + long aliceId = MEMBER_ALICE_ID + 2000; + Chat groupChat = buildGroupChat(chatId, "Recent-scope test", "supergroup"); + + // Step 1: Alice records a model in her PRIVATE chat — this writes a UserRecentModel + // against her personal TelegramUser.id. + User aliceAsUser = resolver.resolveForChat(privateChat(aliceId), apiUser(aliceId, "alice")); + assertTrue(aliceAsUser instanceof TelegramUser); + recordRecentModel(aliceAsUser, "private-only-model"); + + // Step 2: Alice walks into a group chat — resolver produces a TelegramGroup. + User groupOwner = resolver.resolveForChat(groupChat, apiUser(aliceId, "alice")); + assertTrue(groupOwner instanceof TelegramGroup); + assertNotEquals(aliceAsUser.getId(), groupOwner.getId(), + "group row and Alice's TelegramUser must be distinct"); + + // Step 3: Recent-models for the GROUP owner id must NOT include Alice's private pick. + // This is the fix for the production regression "I see my private recents in the group". + var groupRecent = recentModelNamesFor(groupOwner.getId()); + assertFalse(groupRecent.contains("private-only-model"), + "group must not see Alice's private chat recent models; got: " + groupRecent); + + // Step 4: A model picked IN the group writes against the GROUP id. + recordRecentModel(groupOwner, "group-only-model"); + var groupRecentAfter = recentModelNamesFor(groupOwner.getId()); + assertTrue(groupRecentAfter.contains("group-only-model"), + "group's recent list must carry models picked inside the group"); + // Her private recents still have the private-only model, no group leakage in the other direction. + var privateRecent = recentModelNamesFor(aliceAsUser.getId()); + assertTrue(privateRecent.contains("private-only-model")); + assertFalse(privateRecent.contains("group-only-model"), + "private chat must not see group's recent models; got: " + privateRecent); + } + + private void recordRecentModel(User owner, String modelName) { + UserRecentModel row = new UserRecentModel(); + row.setUser(userRepository.findById(owner.getId()).orElseThrow()); + row.setModelName(modelName); + row.setLastUsedAt(java.time.OffsetDateTime.now()); + userRecentModelRepository.save(row); + } + + private java.util.List recentModelNamesFor(Long ownerId) { + return userRecentModelRepository + .findTopByUser(ownerId, org.springframework.data.domain.PageRequest.of(0, 8)) + .stream().map(UserRecentModel::getModelName).toList(); + } + + @Test + @Transactional + @DisplayName("first group message by any member lazily creates a TelegramGroup row") + void shouldLazilyCreateGroupOnFirstInteraction() { + long chatId = GROUP_CHAT_ID - 1; + Chat chat = buildGroupChat(chatId, "Fresh team", "supergroup"); + assertTrue(telegramGroupRepository.findByTelegramId(chatId).isEmpty(), + "Pre-condition: group must not exist yet"); + + User owner = resolver.resolveForChat(chat, apiUser(MEMBER_ALICE_ID, "alice")); + + assertTrue(owner instanceof TelegramGroup); + TelegramGroup groupOwner = (TelegramGroup) owner; + assertEquals(chatId, groupOwner.getTelegramId()); + assertEquals("Fresh team", groupOwner.getTitle()); + assertEquals("supergroup", groupOwner.getType()); + assertNull(groupOwner.getLanguageCode(), "language is unset until /language runs"); + assertNull(groupOwner.getPreferredModelId(), "model is unset until /model runs"); + + Optional found = telegramGroupRepository.findByTelegramId(chatId); + assertTrue(found.isPresent()); + assertEquals(groupOwner.getId(), found.get().getId()); + } + + @Test + @Transactional + @DisplayName("second resolve for the same group returns the same row (idempotent)") + void shouldReturnSameGroupEntityOnRepeatedResolve() { + long chatId = GROUP_CHAT_ID - 2; + Chat chat = buildGroupChat(chatId, "Persistent team", "group"); + + User first = resolver.resolveForChat(chat, apiUser(MEMBER_ALICE_ID, "alice")); + User second = resolver.resolveForChat(chat, apiUser(MEMBER_BOB_ID, "bob")); + + assertTrue(first instanceof TelegramGroup); + assertTrue(second instanceof TelegramGroup); + assertEquals(((TelegramGroup) first).getId(), ((TelegramGroup) second).getId(), + "Bob's resolve must hit the same telegram_group row Alice created"); + assertEquals(1, telegramGroupRepository.findAll().stream() + .filter(g -> chatId == g.getTelegramId()).count(), + "Exactly one row per chat id"); + } + + @Test + @Transactional + @DisplayName("settings written by member A are readable by member B in the same group") + void shouldShareSettingsBetweenGroupMembers() { + long chatId = GROUP_CHAT_ID - 3; + Chat chat = buildGroupChat(chatId, "Shared settings", "supergroup"); + + User ownerFromAlice = resolver.resolveForChat(chat, apiUser(MEMBER_ALICE_ID, "alice")); + chatSettingsService.updateLanguageCode(ownerFromAlice, "ru"); + chatSettingsService.setPreferredModel(ownerFromAlice, "openrouter/claude-sonnet-4"); + chatSettingsService.updateThinkingMode(ownerFromAlice, ThinkingMode.SHOW_ALL); + chatSettingsService.updateAgentMode(ownerFromAlice, true); + + User ownerFromBob = resolver.resolveForChat(chat, apiUser(MEMBER_BOB_ID, "bob")); + assertTrue(ownerFromBob instanceof TelegramGroup); + TelegramGroup reloaded = telegramGroupRepository.findByTelegramId(chatId).orElseThrow(); + assertEquals("ru", reloaded.getLanguageCode(), "language set by Alice must be visible to Bob"); + assertEquals("openrouter/claude-sonnet-4", reloaded.getPreferredModelId(), + "model set by Alice must be visible to Bob"); + assertEquals(ThinkingMode.SHOW_ALL, reloaded.getThinkingMode()); + assertTrue(reloaded.getAgentModeEnabled()); + } + + @Test + @Transactional + @DisplayName("private-chat resolve returns TelegramUser, not TelegramGroup") + void shouldReturnTelegramUserForPrivateChat() { + Chat privateChat = new Chat(); + privateChat.setId(MEMBER_ALICE_ID); + privateChat.setType("private"); + + User owner = resolver.resolveForChat(privateChat, apiUser(MEMBER_ALICE_ID, "alice")); + + assertTrue(owner instanceof TelegramUser, + "Private chats must produce a TelegramUser, got " + owner.getClass().getSimpleName()); + assertEquals(MEMBER_ALICE_ID, ((TelegramUser) owner).getTelegramId()); + } + + @Test + @Transactional + @DisplayName("ChatOwnerLookup.findByChatId routes by sign: negative → group, positive → user") + void shouldRouteChatOwnerLookupByChatIdSign() { + long chatId = GROUP_CHAT_ID - 4; + resolver.resolveForChat(buildGroupChat(chatId, "lookup target", "supergroup"), + apiUser(MEMBER_ALICE_ID, "alice")); + resolver.resolveForChat(privateChat(MEMBER_BOB_ID), apiUser(MEMBER_BOB_ID, "bob")); + + Optional groupOwner = chatOwnerLookup.findByChatId(chatId); + assertTrue(groupOwner.isPresent()); + assertTrue(groupOwner.get() instanceof TelegramGroup); + + Optional userOwner = chatOwnerLookup.findByChatId(MEMBER_BOB_ID); + assertTrue(userOwner.isPresent()); + assertTrue(userOwner.get() instanceof TelegramUser); + + assertTrue(chatOwnerLookup.findByChatId(-999999999999L).isEmpty()); + assertTrue(chatOwnerLookup.findByChatId(999999999999L).isEmpty()); + } + + @Test + @Transactional + @DisplayName("updateGroupInfo picks up title/type changes on subsequent resolve") + void shouldRefreshTitleAndTypeOnSubsequentResolve() { + long chatId = GROUP_CHAT_ID - 5; + resolver.resolveForChat(buildGroupChat(chatId, "Original", "group"), apiUser(MEMBER_ALICE_ID, "alice")); + + User after = resolver.resolveForChat(buildGroupChat(chatId, "Renamed", "supergroup"), + apiUser(MEMBER_BOB_ID, "bob")); + assertNotNull(after); + TelegramGroup reloaded = telegramGroupRepository.findByTelegramId(chatId).orElseThrow(); + assertEquals("Renamed", reloaded.getTitle()); + assertEquals("supergroup", reloaded.getType()); + } + + @Test + @Transactional + @DisplayName("group member switch does not change the group's settings (they belong to the group row)") + void shouldNotLeakSettingsAcrossInvokersInGroup() { + long chatId = GROUP_CHAT_ID - 6; + long aliceId = MEMBER_ALICE_ID + 1000; + long bobId = MEMBER_BOB_ID + 1000; + Chat chat = buildGroupChat(chatId, "Stable settings", "supergroup"); + + User ownerFromAlice = resolver.resolveForChat(chat, apiUser(aliceId, "alice")); + chatSettingsService.updateLanguageCode(ownerFromAlice, "ru"); + + User ownerFromBob = resolver.resolveForChat(chat, apiUser(bobId, "bob")); + assertTrue(ownerFromBob instanceof TelegramGroup); + + TelegramGroup groupRow = telegramGroupRepository.findByTelegramId(chatId).orElseThrow(); + assertEquals("ru", groupRow.getLanguageCode(), + "group's languageCode was set by Alice's update; Bob sees the same row"); + + // Alice's resolved owner is the SAME row Bob sees — one entity, shared by both. + assertEquals(((TelegramGroup) ownerFromAlice).getId(), groupRow.getId()); + assertEquals(((TelegramGroup) ownerFromBob).getId(), groupRow.getId()); + + // Settings-leak sanity: the group row is in telegram_group (positive discriminator), + // not in telegram_user — so no TelegramUser row could have been accidentally mutated + // with the group's language. Verify the chat_id lookup does NOT land in telegram_user. + Optional lookupByGroupChatId = chatOwnerLookup.findByChatId(chatId); + assertTrue(lookupByGroupChatId.isPresent()); + assertTrue(lookupByGroupChatId.get() instanceof TelegramGroup, + "chat_id must resolve to TelegramGroup, not TelegramUser — otherwise settings would leak"); + assertEquals(groupRow.getId(), lookupByGroupChatId.get().getId()); + // Suppress "bobId / aliceId unused" warnings — kept in the arrange block as production mirror. + assertNotEquals(0L, bobId); + assertNotEquals(0L, aliceId); + } + + // ---------- helpers ---------- + + private static Chat buildGroupChat(long chatId, String title, String type) { + Chat chat = new Chat(); + chat.setId(chatId); + chat.setTitle(title); + chat.setType(type); + return chat; + } + + private static Chat privateChat(long userId) { + Chat chat = new Chat(); + chat.setId(userId); + chat.setType("private"); + return chat; + } + + private static org.telegram.telegrambots.meta.api.objects.User apiUser(long id, String username) { + org.telegram.telegrambots.meta.api.objects.User u = new org.telegram.telegrambots.meta.api.objects.User(); + u.setId(id); + u.setUserName(username); + u.setFirstName(username); + u.setIsBot(false); + return u; + } + + /** + * Safety-net no-op used for IDE "unused import" prevention when trimming helpers; + * kept as a documented marker that {@link #shouldNotLeakSettingsAcrossInvokersInGroup} + * relies on {@code assertFalse} imports even if not reached in every code path. + */ + @SuppressWarnings("unused") + private static void pin() { + assertFalse(false); + } +} diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java index da88e414..b29f91a0 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java @@ -5,7 +5,13 @@ import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; import io.github.ngirchev.opendaimon.telegram.service.TelegramFileService; -import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup; +import io.github.ngirchev.opendaimon.common.repository.UserRepository; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsOwnerResolver; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatOwnerLookup; +import io.github.ngirchev.opendaimon.telegram.service.TelegramGroupService; +import io.github.ngirchev.opendaimon.telegram.repository.TelegramGroupRepository; import io.github.ngirchev.opendaimon.common.storage.service.FileStorageService; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @@ -276,7 +282,9 @@ public TelegramMessageService telegramMessageService( MessageLocalizationService messageLocalizationService, ObjectProvider storagePropertiesProvider, ConversationThreadService conversationThreadService, - ObjectProvider telegramMessageServiceSelfProvider + ObjectProvider telegramMessageServiceSelfProvider, + ChatOwnerLookup chatOwnerLookup, + ChatSettingsService chatSettingsService ) { return new TelegramMessageService( messageService, @@ -285,7 +293,9 @@ public TelegramMessageService telegramMessageService( messageLocalizationService, storagePropertiesProvider, conversationThreadService, - telegramMessageServiceSelfProvider + telegramMessageServiceSelfProvider, + chatOwnerLookup, + chatSettingsService ); } @@ -320,23 +330,42 @@ public RecordingTelegramBot telegramBot( } @Bean - public UserModelPreferenceService userModelPreferenceService( - TelegramUserRepository telegramUserRepository) { - return new UserModelPreferenceService(telegramUserRepository); + public TelegramGroupService telegramGroupService( + TelegramGroupRepository telegramGroupRepository, + io.github.ngirchev.opendaimon.common.service.AssistantRoleService assistantRoleService) { + return new TelegramGroupService(telegramGroupRepository, assistantRoleService, false); + } + + @Bean + public ChatSettingsService chatSettingsService( + TelegramUserService telegramUserService, + TelegramGroupService telegramGroupService) { + return new ChatSettingsService(telegramUserService, telegramGroupService); + } + + @Bean + public ChatSettingsOwnerResolver chatSettingsOwnerResolver( + TelegramUserService telegramUserService, + TelegramGroupService telegramGroupService) { + return new ChatSettingsOwnerResolver(telegramUserService, telegramGroupService); + } + + @Bean + public ChatOwnerLookup chatOwnerLookup(ChatSettingsOwnerResolver resolver) { + return new TelegramChatOwnerLookup(resolver); } @Bean public PersistentKeyboardService persistentKeyboardService( - UserModelPreferenceService userModelPreferenceService, CoreCommonProperties coreCommonProperties, ObjectProvider telegramBotProvider, TelegramProperties telegramProperties, MessageLocalizationService messageLocalizationService, - TelegramUserRepository telegramUserRepository + UserRepository userRepository ) { return new PersistentKeyboardService( - userModelPreferenceService, coreCommonProperties, telegramBotProvider, telegramProperties, - messageLocalizationService, telegramUserRepository); + coreCommonProperties, telegramBotProvider, telegramProperties, + messageLocalizationService, userRepository); } @Bean @@ -360,7 +389,7 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( OpenDaimonMessageService messageService, AIRequestPipeline aiRequestPipeline, TelegramProperties telegramProperties, - UserModelPreferenceService userModelPreferenceService, + ChatSettingsService chatSettingsService, PersistentKeyboardService persistentKeyboardService, ReplyImageAttachmentService replyImageAttachmentService ) { @@ -369,7 +398,7 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( TelegramMessageHandlerActions actions = new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, - userModelPreferenceService, persistentKeyboardService, replyImageAttachmentService, + chatSettingsService, persistentKeyboardService, replyImageAttachmentService, messageSender, null, null, 10, false); ExDomainFsm handlerFsm = MessageHandlerFsmFactory.create(actions); diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java index d3568122..80521c8c 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java @@ -4,7 +4,13 @@ import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; import io.github.ngirchev.opendaimon.telegram.service.TelegramFileService; -import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup; +import io.github.ngirchev.opendaimon.common.repository.UserRepository; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsOwnerResolver; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatOwnerLookup; +import io.github.ngirchev.opendaimon.telegram.service.TelegramGroupService; +import io.github.ngirchev.opendaimon.telegram.repository.TelegramGroupRepository; import io.github.ngirchev.opendaimon.common.storage.service.FileStorageService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -249,7 +255,9 @@ public TelegramMessageService telegramMessageService( MessageLocalizationService messageLocalizationService, ObjectProvider storagePropertiesProvider, ConversationThreadService conversationThreadService, - ObjectProvider telegramMessageServiceSelfProvider) { + ObjectProvider telegramMessageServiceSelfProvider, + ChatOwnerLookup chatOwnerLookup, + ChatSettingsService chatSettingsService) { return new TelegramMessageService( messageService, telegramUserService, @@ -257,7 +265,9 @@ public TelegramMessageService telegramMessageService( messageLocalizationService, storagePropertiesProvider, conversationThreadService, - telegramMessageServiceSelfProvider + telegramMessageServiceSelfProvider, + chatOwnerLookup, + chatSettingsService ); } @@ -274,24 +284,43 @@ public TypingIndicatorService typingIndicatorService() { } @Bean - public UserModelPreferenceService userModelPreferenceService( - TelegramUserRepository telegramUserRepository) { - return new UserModelPreferenceService(telegramUserRepository); + public TelegramGroupService telegramGroupService( + TelegramGroupRepository telegramGroupRepository, + io.github.ngirchev.opendaimon.common.service.AssistantRoleService assistantRoleService) { + return new TelegramGroupService(telegramGroupRepository, assistantRoleService, false); + } + + @Bean + public ChatSettingsService chatSettingsService( + TelegramUserService telegramUserService, + TelegramGroupService telegramGroupService) { + return new ChatSettingsService(telegramUserService, telegramGroupService); + } + + @Bean + public ChatSettingsOwnerResolver chatSettingsOwnerResolver( + TelegramUserService telegramUserService, + TelegramGroupService telegramGroupService) { + return new ChatSettingsOwnerResolver(telegramUserService, telegramGroupService); + } + + @Bean + public ChatOwnerLookup chatOwnerLookup(ChatSettingsOwnerResolver resolver) { + return new TelegramChatOwnerLookup(resolver); } @Bean @Primary public PersistentKeyboardService persistentKeyboardService( - UserModelPreferenceService userModelPreferenceService, CoreCommonProperties coreCommonProperties, ObjectProvider telegramBotProvider, TelegramProperties telegramProperties, MessageLocalizationService messageLocalizationService, - TelegramUserRepository telegramUserRepository + UserRepository userRepository ) { return new PersistentKeyboardService( - userModelPreferenceService, coreCommonProperties, telegramBotProvider, telegramProperties, - messageLocalizationService, telegramUserRepository); + coreCommonProperties, telegramBotProvider, telegramProperties, + messageLocalizationService, userRepository); } @Bean @@ -316,7 +345,7 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( OpenDaimonMessageService messageService, AIRequestPipeline aiRequestPipeline, TelegramProperties telegramProperties, - UserModelPreferenceService userModelPreferenceService, + ChatSettingsService chatSettingsService, PersistentKeyboardService persistentKeyboardService, ReplyImageAttachmentService replyImageAttachmentService) { TelegramMessageSender messageSender = new TelegramMessageSender( @@ -324,7 +353,7 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( TelegramMessageHandlerActions actions = new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, - userModelPreferenceService, persistentKeyboardService, replyImageAttachmentService, + chatSettingsService, persistentKeyboardService, replyImageAttachmentService, messageSender, null, null, 10, false); ExDomainFsm handlerFsm = MessageHandlerFsmFactory.create(actions); diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/repository/TelegramGroupRepositoryIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/repository/TelegramGroupRepositoryIT.java new file mode 100644 index 00000000..f6ff92c4 --- /dev/null +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/repository/TelegramGroupRepositoryIT.java @@ -0,0 +1,172 @@ +package io.github.ngirchev.opendaimon.it.telegram.repository; + +import io.github.ngirchev.opendaimon.common.config.CoreFlywayConfig; +import io.github.ngirchev.opendaimon.common.config.CoreJpaConfig; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; +import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.common.repository.UserRepository; +import io.github.ngirchev.opendaimon.telegram.config.TelegramFlywayConfig; +import io.github.ngirchev.opendaimon.telegram.config.TelegramJpaConfig; +import io.github.ngirchev.opendaimon.telegram.model.TelegramGroup; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.repository.TelegramGroupRepository; +import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserRepository; +import io.github.ngirchev.opendaimon.test.AbstractContainerIT; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.OffsetDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test for {@link TelegramGroupRepository} + Flyway V3 migration + + * JOINED inheritance mapping against a real Postgres (Testcontainers). + *

+ * Verifies the Stage 1 migration actually applies (a failing migration would block + * context startup), the {@code telegram_group} child table is populated correctly + * with the discriminator {@code TELEGRAM_GROUP}, and polymorphic queries through the + * base {@link UserRepository} return the subtype instance. + */ +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({ + CoreJpaConfig.class, + TelegramJpaConfig.class, + CoreFlywayConfig.class, + TelegramFlywayConfig.class +}) +class TelegramGroupRepositoryIT extends AbstractContainerIT { + + private static final Long GROUP_CHAT_ID = -1001234567890L; + + @Autowired + private TelegramGroupRepository telegramGroupRepository; + + @Autowired + private TelegramUserRepository telegramUserRepository; + + @Autowired + private UserRepository userRepository; + + @Test + @DisplayName("save + findByTelegramId round-trip populates the V3 telegram_group table") + void shouldSaveAndLoadTelegramGroupByChatId() { + TelegramGroup group = buildGroup(GROUP_CHAT_ID, "DevOps team", "supergroup"); + group.setLanguageCode("ru"); + group.setPreferredModelId("openrouter/claude-sonnet-4"); + group.setAgentModeEnabled(true); + group.setThinkingMode(ThinkingMode.SHOW_ALL); + group.setMenuVersionHash("deadbeef"); + TelegramGroup saved = telegramGroupRepository.save(group); + + assertNotNull(saved.getId()); + + Optional found = telegramGroupRepository.findByTelegramId(GROUP_CHAT_ID); + assertTrue(found.isPresent()); + TelegramGroup loaded = found.get(); + assertEquals(GROUP_CHAT_ID, loaded.getTelegramId()); + assertEquals("DevOps team", loaded.getTitle()); + assertEquals("supergroup", loaded.getType()); + assertEquals("ru", loaded.getLanguageCode()); + assertEquals("openrouter/claude-sonnet-4", loaded.getPreferredModelId()); + assertTrue(loaded.getAgentModeEnabled()); + assertEquals(ThinkingMode.SHOW_ALL, loaded.getThinkingMode()); + assertEquals("deadbeef", loaded.getMenuVersionHash()); + } + + @Test + @DisplayName("existsByTelegramId correctly reflects presence in telegram_group") + void shouldReportExistenceByChatId() { + assertFalse(telegramGroupRepository.existsByTelegramId(GROUP_CHAT_ID)); + telegramGroupRepository.save(buildGroup(GROUP_CHAT_ID, "g", "group")); + assertTrue(telegramGroupRepository.existsByTelegramId(GROUP_CHAT_ID)); + } + + @Test + @DisplayName("polymorphic UserRepository.findById returns TelegramGroup subtype via discriminator TELEGRAM_GROUP") + void shouldReturnTelegramGroupThroughPolymorphicUserRepository() { + TelegramGroup saved = telegramGroupRepository.save(buildGroup(GROUP_CHAT_ID, "polymorphic test", "group")); + + Optional found = userRepository.findById(saved.getId()); + + assertTrue(found.isPresent(), "Base UserRepository must see the subtype via JOINED inheritance"); + User loaded = found.get(); + assertTrue(loaded instanceof TelegramGroup, + "Expected TelegramGroup via discriminator, got " + loaded.getClass().getSimpleName()); + } + + @Test + @DisplayName("TelegramUser and TelegramGroup coexist with distinct discriminators; chat_id namespaces do not collide") + void shouldCoexistWithTelegramUserUnderSameBaseTable() { + Long privateChatId = 42L; + TelegramUser user = new TelegramUser(); + user.setTelegramId(privateChatId); + user.setUsername("alice"); + OffsetDateTime now = OffsetDateTime.now(); + user.setCreatedAt(now); + user.setUpdatedAt(now); + user.setLastActivityAt(now); + user.setIsAdmin(false); + user.setIsPremium(false); + user.setIsBlocked(false); + user.setThinkingMode(ThinkingMode.HIDE_REASONING); + TelegramUser savedUser = telegramUserRepository.save(user); + + TelegramGroup group = telegramGroupRepository.save(buildGroup(GROUP_CHAT_ID, "group", "supergroup")); + + // UserRepository.findById on the user's numeric id returns a TelegramUser. + User userAsBase = userRepository.findById(savedUser.getId()).orElseThrow(); + assertTrue(userAsBase instanceof TelegramUser); + // UserRepository.findById on the group's numeric id returns a TelegramGroup. + User groupAsBase = userRepository.findById(group.getId()).orElseThrow(); + assertTrue(groupAsBase instanceof TelegramGroup); + + // Queries keyed on telegram_id hit the correct child table: + assertTrue(telegramUserRepository.findByTelegramId(privateChatId).isPresent()); + assertTrue(telegramGroupRepository.findByTelegramId(GROUP_CHAT_ID).isPresent()); + assertTrue(telegramUserRepository.findByTelegramId(GROUP_CHAT_ID).isEmpty(), + "Group chat_id must not leak into telegram_user child table"); + assertTrue(telegramGroupRepository.findByTelegramId(privateChatId).isEmpty(), + "Private chat user id must not leak into telegram_group child table"); + } + + @Test + @DisplayName("fresh group defaults: nullable fields land as null in DB, not implicit values") + void shouldPersistFreshGroupWithNullableDefaults() { + TelegramGroup fresh = buildGroup(GROUP_CHAT_ID, "fresh", "group"); + TelegramGroup saved = telegramGroupRepository.save(fresh); + + assertNotNull(saved.getId()); + assertNull(saved.getLanguageCode(), "languageCode must stay null until /language is invoked"); + assertNull(saved.getPreferredModelId(), "preferredModelId must stay null until /model is invoked"); + assertNull(saved.getMenuVersionHash(), "menuVersionHash must stay null until first menu reconcile"); + } + + private static TelegramGroup buildGroup(Long chatId, String title, String type) { + TelegramGroup group = new TelegramGroup(); + group.setTelegramId(chatId); + group.setTitle(title); + group.setType(type); + OffsetDateTime now = OffsetDateTime.now(); + group.setCreatedAt(now); + group.setUpdatedAt(now); + group.setLastActivityAt(now); + group.setIsAdmin(false); + group.setIsPremium(false); + group.setIsBlocked(false); + group.setThinkingMode(ThinkingMode.HIDE_REASONING); + return group; + } +} diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/CoreAutoConfig.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/CoreAutoConfig.java index 14c55efa..c20d23ec 100644 --- a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/CoreAutoConfig.java +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/CoreAutoConfig.java @@ -71,6 +71,12 @@ public RestTemplate restTemplate(RestTemplateBuilder builder) { return builder.build(); } + @Bean + @ConditionalOnMissingBean + public ChatOwnerLookup chatOwnerLookup() { + return ChatOwnerLookup.NOOP; + } + @Bean @ConditionalOnMissingBean(MessageSource.class) public MessageSource messageSource() { @@ -259,12 +265,14 @@ public SummarizationService summarizationService( ConversationThreadService threadService, AIGatewayRegistry aiGatewayRegistry, CoreCommonProperties coreCommonProperties, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + ChatOwnerLookup chatOwnerLookup) { return new SummarizationService( threadService, aiGatewayRegistry, coreCommonProperties, - objectMapper + objectMapper, + chatOwnerLookup ); } diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/service/ChatOwnerLookup.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/service/ChatOwnerLookup.java new file mode 100644 index 00000000..ac50b8eb --- /dev/null +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/service/ChatOwnerLookup.java @@ -0,0 +1,24 @@ +package io.github.ngirchev.opendaimon.common.service; + +import io.github.ngirchev.opendaimon.common.model.User; + +import java.util.Optional; + +/** + * Cross-module SPI: resolves the settings-owner {@link User} for a given + * Telegram {@code chat_id} (or any other scoped id carried by a + * {@code ConversationThread}). Lives in {@code opendaimon-common} so that + * summarization and other common-side paths can seed per-chat settings + * without importing the Telegram module. + *

+ * Default binding returns {@link Optional#empty()} — enough for non-Telegram + * deployments. The Telegram module provides an implementation that delegates + * to its {@code ChatSettingsOwnerResolver}. + */ +public interface ChatOwnerLookup { + + Optional findByChatId(Long chatId); + + /** No-op fallback when no Telegram module is present. */ + ChatOwnerLookup NOOP = chatId -> Optional.empty(); +} diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/service/SummarizationService.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/service/SummarizationService.java index 554cd149..20e26e01 100644 --- a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/service/SummarizationService.java +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/service/SummarizationService.java @@ -5,11 +5,15 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import io.github.ngirchev.opendaimon.common.ai.command.AICommand; import io.github.ngirchev.opendaimon.common.ai.command.ChatAICommand; import io.github.ngirchev.opendaimon.common.config.CoreCommonProperties; import io.github.ngirchev.opendaimon.common.model.ConversationThread; import io.github.ngirchev.opendaimon.common.model.OpenDaimonMessage; import io.github.ngirchev.opendaimon.common.model.MessageRole; +import io.github.ngirchev.opendaimon.common.model.ThreadScopeKind; +import io.github.ngirchev.opendaimon.common.model.User; +import java.util.Optional; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -38,6 +42,7 @@ public class SummarizationService { private final AIGatewayRegistry aiGatewayRegistry; private final CoreCommonProperties coreCommonProperties; private final ObjectMapper objectMapper; + private final ChatOwnerLookup chatOwnerLookup; // Sync by threadKey to prevent concurrent summarization private final Set ongoingSummarizations = ConcurrentHashMap.newKeySet(); @@ -92,13 +97,27 @@ private void performSummarization(ConversationThread thread, List resolveChatOwnerPreferredModel(ConversationThread thread) { + if (thread == null || thread.getScopeKind() != ThreadScopeKind.TELEGRAM_CHAT || thread.getScopeId() == null) { + return Optional.empty(); + } + Optional owner = chatOwnerLookup.findByChatId(thread.getScopeId()); + return owner.map(User::getPreferredModelId) + .filter(id -> id != null && !id.isBlank()); + } + private String buildDialogTextForSummarization(ConversationThread thread, List messages) { StringBuilder dialogText = new StringBuilder(); if (thread.getSummary() != null && !thread.getSummary().isEmpty()) { @@ -121,12 +140,18 @@ private String buildDialogTextForSummarization(ConversationThread thread, List summarizationMetadata = new HashMap<>(); + resolveChatOwnerPreferredModel(thread) + .ifPresent(modelId -> summarizationMetadata.put(AICommand.PREFERRED_MODEL_ID_FIELD, modelId)); ChatAICommand summaryCommand = new ChatAICommand( Set.of(SUMMARIZATION), Set.of(), 0.3, coreCommonProperties.getSummarization().getMaxOutputTokens(), null, summarizationPrompt, dialogTextStr, false, summarizationMetadata, new HashMap<>(), List.of()); diff --git a/opendaimon-common/src/test/java/io/github/ngirchev/opendaimon/common/service/SummarizationServiceTest.java b/opendaimon-common/src/test/java/io/github/ngirchev/opendaimon/common/service/SummarizationServiceTest.java index 1159acf2..f27bd9c9 100644 --- a/opendaimon-common/src/test/java/io/github/ngirchev/opendaimon/common/service/SummarizationServiceTest.java +++ b/opendaimon-common/src/test/java/io/github/ngirchev/opendaimon/common/service/SummarizationServiceTest.java @@ -33,7 +33,13 @@ import io.github.ngirchev.opendaimon.common.model.OpenDaimonMessage; import io.github.ngirchev.opendaimon.common.model.ConversationThread; import io.github.ngirchev.opendaimon.common.model.MessageRole; +import io.github.ngirchev.opendaimon.common.model.ThreadScopeKind; import io.github.ngirchev.opendaimon.common.model.User; +import org.mockito.ArgumentCaptor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import java.util.Optional; @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -65,7 +71,8 @@ void setUp() { threadService, aiGatewayRegistry, coreCommonProperties, - objectMapper + objectMapper, + io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup.NOOP ); } @@ -121,6 +128,64 @@ void whenModelReturnsNonJsonThenValidJson_thenRetrySucceeds() { verify(threadService).updateThreadSummary(eq(thread), eq("Test summary"), anyList()); } + /** + * Regression for Bug 2026-04-11: summarization in group chats failed with HTTP 400 + * "model is required" because the {@code ChatAICommand.metadata} was empty and + * {@code SpringAIGateway} dispatched an AUTO request without the {@code model} field. + * The fix seeds the chat owner's {@code preferredModelId} via {@link ChatOwnerLookup}. + */ + @Test + void shouldSeedPreferredModelFromChatOwnerIntoSummarizationMetadata() { + long groupChatId = -1001234567890L; + User groupOwner = new User(); + groupOwner.setPreferredModelId("openrouter/claude-sonnet-4"); + ChatOwnerLookup lookup = chatId -> chatId.equals(groupChatId) ? Optional.of(groupOwner) : Optional.empty(); + + ObjectMapper objectMapper = new ObjectMapper(); + SummarizationService withLookup = new SummarizationService( + threadService, aiGatewayRegistry, coreCommonProperties, objectMapper, lookup); + + ConversationThread thread = createThread(1000L); + thread.setScopeKind(ThreadScopeKind.TELEGRAM_CHAT); + thread.setScopeId(groupChatId); + + AIGateway mockGateway = mock(AIGateway.class); + when(aiGatewayRegistry.getSupportedAiGateways(any())).thenReturn(List.of(mockGateway)); + when(mockGateway.generateResponse(any(AICommand.class))) + .thenReturn(responseWithContent("{\"summary\": \"s\", \"memory_bullets\": []}")); + + withLookup.summarizeThread(thread, List.of(createUserMessage("hi"), createAssistantMessage("hi"))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AICommand.class); + verify(mockGateway).generateResponse(captor.capture()); + assertEquals("openrouter/claude-sonnet-4", + captor.getValue().metadata().get(AICommand.PREFERRED_MODEL_ID_FIELD)); + } + + @Test + void shouldNotSeedPreferredModelWhenThreadScopeIsNotTelegramChat() { + ChatOwnerLookup lookup = mock(ChatOwnerLookup.class); + ObjectMapper objectMapper = new ObjectMapper(); + SummarizationService withLookup = new SummarizationService( + threadService, aiGatewayRegistry, coreCommonProperties, objectMapper, lookup); + + ConversationThread thread = createThread(1000L); + thread.setScopeKind(ThreadScopeKind.USER); + thread.setScopeId(42L); + + AIGateway mockGateway = mock(AIGateway.class); + when(aiGatewayRegistry.getSupportedAiGateways(any())).thenReturn(List.of(mockGateway)); + when(mockGateway.generateResponse(any(AICommand.class))) + .thenReturn(responseWithContent("{\"summary\": \"s\", \"memory_bullets\": []}")); + + withLookup.summarizeThread(thread, List.of(createUserMessage("hi"), createAssistantMessage("ok"))); + + verify(lookup, never()).findByChatId(any()); + ArgumentCaptor captor = ArgumentCaptor.forClass(AICommand.class); + verify(mockGateway).generateResponse(captor.capture()); + assertFalse(captor.getValue().metadata().containsKey(AICommand.PREFERRED_MODEL_ID_FIELD)); + } + @Test void whenModelAlwaysReturnsNonJson_thenThrowsAfterRetries() { ConversationThread thread = createThread(1000L); diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index eb3ab0b7..ea67cf42 100644 --- a/opendaimon-telegram/TELEGRAM_MODULE.md +++ b/opendaimon-telegram/TELEGRAM_MODULE.md @@ -45,15 +45,45 @@ onUpdateReceived(Update) ### Group Chat Conceptual Model -A group (or supergroup) is treated as a **single logical participant**, not as a set of individuals. All chat-scoped state — conversation history, current model preference, language selected for the command menu, per-chat menu snapshot — is attached to the `chat_id` and **shared by every member** of the group. There is no per-user-inside-group isolation. +A group (or supergroup) is treated as a **single logical participant**, not as a set of individuals. All chat-scoped state — conversation history, preferred model, bot-menu language, command-menu snapshot, agent mode, thinking mode, assistant role, recent models — belongs to a dedicated `TelegramGroup` row (a JOINED-inheritance subclass of `User` with `@DiscriminatorValue("TELEGRAM_GROUP")`) and is **shared by every member** of the group. There is no per-user-inside-group isolation. -Implications: +#### Settings Owner Resolution + +Every incoming `Update` resolves to exactly one *settings owner* — a polymorphic `User` that owns chat-scoped state for that chat: + +- **Private chat** → the invoker's `TelegramUser` (the chat *is* that person). +- **Group / supergroup chat** → the `TelegramGroup` row keyed on the group `chat_id`. + +Resolution happens once in `TelegramBot.mapToTelegram*` via `ChatSettingsOwnerResolver.resolveForChat(chat, invoker)`. The result is stamped on `TelegramCommand.settingsOwner` and consumed by handlers through `ChatSettingsService`: + +```java +// Language handler — writes go to the owner (group in groups, user in privates) +chatSettingsService.updateLanguageCode(command.settingsOwner(), "ru"); + +// Agent-mode handler — same pattern +chatSettingsService.updateAgentMode(command.settingsOwner(), true); + +// Assistant role — same pattern +chatSettingsService.updateAssistantRole(command.settingsOwner(), customRoleText); +``` + +The facade dispatches by subtype (`instanceof TelegramGroup` → write to `telegram_group`; `instanceof TelegramUser` → write to `telegram_user`). + +#### Implications - The **scope key for Telegram API calls is always `chat_id`**, never `user.telegramId`. In a private chat the two values coincide because Telegram uses the user id as the chat id; in a group they diverge (group `chat_id` is negative, e.g. `-1001234567890`). - `TelegramCommand` has a field named `telegramId`, but it actually stores the **chat id** (see its constructors: `this.telegramId = chatId`). The name is historical and misleading — treat it as `chatId` when reasoning about scope. -- `/language` and `/model` invoked in a group are **last-writer-wins** for the group. The most recent invoker sets the value for everyone in that group. This is intentional — do not add per-user-inside-group state. -- `BotCommandScopeChat(chat_id)` with the group id overrides Default scope for the group. `BotCommandScopeChatMember` (per-user-in-chat) is deliberately unused; it would contradict the shared-chat model. -- Per-chat runtime caches (e.g. an in-memory "which chats we already pushed the current command menu to") must be keyed on `chat_id`, not `user.telegramId`, otherwise they silently miss groups. +- Adding a new chat-scoped setting? Add the field to `User` (inherited by both subclasses) and route reads/writes through `ChatSettingsService` over a `User owner`. Never introduce a code path that keys on `cq.getFrom().getId()` or `user.telegramId` — that reintroduces per-invoker leakage. +- `BotCommandScopeChat(chat_id)` with the group id overrides Default scope for the group. `BotCommandScopeChatMember` (per-user-in-chat) is deliberately unused; it would contradict the shared-chat model. Menu-version hash lives on whichever owner resolved for the chat (`TelegramGroup.menuVersionHash` for groups, `TelegramUser.menuVersionHash` for privates); `TelegramBotMenuService.reconcileMenuIfStale(User owner, Long chatId)` dispatches by subtype and persists via `ChatSettingsService`. +- Summarization (`SummarizationService` in `opendaimon-common`) reads the chat's `preferredModelId` via the `ChatOwnerLookup` SPI (`TelegramChatOwnerLookup` implementation) keyed on `thread.scopeId`. This ensures group chats summarize with their picked model and prevents the "HTTP 400 model is required" regression from empty AUTO-routing bodies. +- Per-chat runtime caches (e.g. in-memory "which chats we already pushed the current command menu to") must be keyed on `chat_id`, not `user.telegramId`, otherwise they silently miss groups. + +#### What is NOT chat-scoped + +Two things stay **per-invoker** even in groups — this is intentional and must not be migrated: + +- **FSM input state** `TelegramUserSession.botStatus` (e.g. "awaiting custom role text"). If Alice starts `/role custom` in a group and Bob sends text first, Alice's FSM must not consume Bob's text. +- **Whitelist / access level** (admin / vip / regular / blocked). Groups have no access level; their members do. `TelegramUserPriorityService` always receives the invoker's id, never the group's. ### Inline Query Policy | Condition | Result | diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java index 5c2ebd48..4db19114 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/TelegramBot.java @@ -45,6 +45,8 @@ import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import io.github.ngirchev.opendaimon.telegram.model.TelegramUserSession; +import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsOwnerResolver; import io.github.ngirchev.opendaimon.telegram.service.TelegramBotMenuService; import io.github.ngirchev.opendaimon.telegram.service.TelegramFileService; import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageCoalescingService; @@ -70,11 +72,12 @@ public class TelegramBot extends TelegramLongPollingBot { private final ObjectProvider fileUploadPropertiesProvider; private final ObjectProvider messageCoalescingServiceProvider; private final ObjectProvider menuServiceProvider; + private final ObjectProvider ownerResolverProvider; public TelegramBot(TelegramProperties config, CommandSyncService commandSyncService, TelegramUserService userService) { - this(config, new DefaultBotOptions(), commandSyncService, userService, null, null, null, null, null); + this(config, new DefaultBotOptions(), commandSyncService, userService, null, null, null, null, null, null); } /** @@ -88,7 +91,7 @@ public TelegramBot(TelegramProperties config, ObjectProvider fileServiceProvider, ObjectProvider fileUploadPropertiesProvider) { this(config, botOptions, commandSyncService, userService, messageLocalizationService, - fileServiceProvider, fileUploadPropertiesProvider, null, null); + fileServiceProvider, fileUploadPropertiesProvider, null, null, null); } public TelegramBot(TelegramProperties config, @@ -100,7 +103,7 @@ public TelegramBot(TelegramProperties config, ObjectProvider fileUploadPropertiesProvider, ObjectProvider messageCoalescingServiceProvider) { this(config, botOptions, commandSyncService, userService, messageLocalizationService, - fileServiceProvider, fileUploadPropertiesProvider, messageCoalescingServiceProvider, null); + fileServiceProvider, fileUploadPropertiesProvider, messageCoalescingServiceProvider, null, null); } public TelegramBot(TelegramProperties config, @@ -112,6 +115,21 @@ public TelegramBot(TelegramProperties config, ObjectProvider fileUploadPropertiesProvider, ObjectProvider messageCoalescingServiceProvider, ObjectProvider menuServiceProvider) { + this(config, botOptions, commandSyncService, userService, messageLocalizationService, + fileServiceProvider, fileUploadPropertiesProvider, messageCoalescingServiceProvider, + menuServiceProvider, null); + } + + public TelegramBot(TelegramProperties config, + DefaultBotOptions botOptions, + CommandSyncService commandSyncService, + TelegramUserService userService, + MessageLocalizationService messageLocalizationService, + ObjectProvider fileServiceProvider, + ObjectProvider fileUploadPropertiesProvider, + ObjectProvider messageCoalescingServiceProvider, + ObjectProvider menuServiceProvider, + ObjectProvider ownerResolverProvider) { super(botOptions, config.getToken()); this.config = config; this.commandSyncService = commandSyncService; @@ -121,6 +139,7 @@ public TelegramBot(TelegramProperties config, this.fileUploadPropertiesProvider = fileUploadPropertiesProvider; this.messageCoalescingServiceProvider = messageCoalescingServiceProvider; this.menuServiceProvider = menuServiceProvider; + this.ownerResolverProvider = ownerResolverProvider; } @Override @@ -440,7 +459,13 @@ private void sendFileUploadDisabledReply(Update update) { String langCode = null; try { TelegramUser user = userService.getOrCreateUser(update.getMessage().getFrom()); - langCode = user.getLanguageCode(); + // Prefer the settings-owner's language so the disabled-reply is localised for + // the whole group, not just the member who triggered the upload. Fall back to + // the invoker when the owner has no language set yet (fresh group) or when + // the resolver bean is unavailable (bare-bones test harness). + User owner = resolveSettingsOwner( + update.getMessage().getChat(), update.getMessage().getFrom(), user); + langCode = resolveLanguageCode(owner, user); } catch (Exception ignored) { } String msg = messageLocalizationService != null @@ -485,10 +510,11 @@ private static Integer getReplyToMessageId(Update update) { * whose command set diverges from the current build. Must not block or throw — any * Telegram API failure is swallowed at the call site and the command processing continues. * - * @param user resolved Telegram user (never {@code null}) + * @param owner settings owner (TelegramUser in private chats, TelegramGroup in groups) + * @param chatId Telegram chat id — the {@code BotCommandScopeChat} target */ - private void reconcileMenuIfStale(TelegramUser user) { - if (menuServiceProvider == null) { + private void reconcileMenuIfStale(User owner, Long chatId) { + if (menuServiceProvider == null || owner == null || chatId == null) { return; } TelegramBotMenuService menuService = menuServiceProvider.getIfAvailable(); @@ -496,13 +522,10 @@ private void reconcileMenuIfStale(TelegramUser user) { return; } try { - boolean changed = menuService.reconcileMenuIfStale(user); - if (changed) { - userService.updateMenuVersionHash(user.getTelegramId(), user.getMenuVersionHash()); - } + menuService.reconcileMenuIfStale(owner, chatId); + // Persistence of the new hash is handled inside the menu service polymorphically. } catch (Exception e) { - log.warn("Lazy menu reconciliation failed for chatId={}: {}", - user != null ? user.getTelegramId() : null, e.getMessage()); + log.warn("Lazy menu reconciliation failed for chatId={}: {}", chatId, e.getMessage()); } } @@ -517,12 +540,43 @@ private boolean isFileUploadEnabled() { return props != null && Boolean.TRUE.equals(props.getEnabled()); } + /** + * Resolves the chat-scoped settings owner for an incoming update. + * In a group/supergroup returns the {@code TelegramGroup} row; in a private chat returns + * the invoker's {@code TelegramUser}. When the resolver bean is unavailable (legacy tests, + * minimal bootstrap) falls back to the invoker to preserve old behavior. + */ + private User resolveSettingsOwner(org.telegram.telegrambots.meta.api.objects.Chat chat, + org.telegram.telegrambots.meta.api.objects.User invoker, + TelegramUser invokerEntity) { + ChatSettingsOwnerResolver resolver = ownerResolverProvider != null + ? ownerResolverProvider.getIfAvailable() : null; + if (resolver == null || chat == null || invoker == null) { + return invokerEntity; + } + return resolver.resolveForChat(chat, invoker); + } + + /** + * Returns the language code from the settings owner, falling back to the invoker's user when + * the group has no language yet (e.g. first interaction before {@code /language}). + */ + private String resolveLanguageCode(User owner, TelegramUser invokerEntity) { + if (owner != null && owner.getLanguageCode() != null && !owner.getLanguageCode().isBlank()) { + return owner.getLanguageCode(); + } + return invokerEntity != null ? invokerEntity.getLanguageCode() : null; + } + protected TelegramCommand mapToTelegramCommand(Update update) { CallbackQuery cq = update.getCallbackQuery(); var message = cq.getMessage(); TelegramUser telegramUser = userService.getOrCreateUser(cq.getFrom()); - reconcileMenuIfStale(telegramUser); Long userId = telegramUser.getId(); + org.telegram.telegrambots.meta.api.objects.Chat callbackChat = + (message instanceof Message accessibleMessage) ? accessibleMessage.getChat() : null; + User settingsOwner = resolveSettingsOwner(callbackChat, cq.getFrom(), telegramUser); + reconcileMenuIfStale(settingsOwner, message != null ? message.getChatId() : null); TelegramCommandType telegramCommandType = null; String callbackData = cq.getData(); @@ -547,13 +601,15 @@ protected TelegramCommand mapToTelegramCommand(Update update) { } TelegramCommand cmd = new TelegramCommand(userId, message.getChatId(), telegramCommandType, update, true); - return cmd.languageCode(telegramUser.getLanguageCode()); + cmd.settingsOwner(settingsOwner); + return cmd.languageCode(resolveLanguageCode(settingsOwner, telegramUser)); } protected TelegramCommand mapToTelegramTextCommand(Update update) { var message = update.getMessage(); TelegramUser telegramUser = userService.getOrCreateUser(message.getFrom()); Long userId = telegramUser.getId(); + User settingsOwner = resolveSettingsOwner(message.getChat(), message.getFrom(), telegramUser); String forwardInfo = extractForwardInfo(message); String userText; @@ -566,7 +622,7 @@ protected TelegramCommand mapToTelegramTextCommand(Update update) { userText = enrichWithForwardContext(stripped, forwardInfo, telegramUser.getLanguageCode()); } else if (stripped.startsWith("/")) { clearStatus(telegramUser.getTelegramId()); - reconcileMenuIfStale(telegramUser); + reconcileMenuIfStale(settingsOwner, message.getChatId()); int spaceIndex = stripped.indexOf(' '); String commandToken = stripped.substring(0, spaceIndex == -1 ? stripped.length() : spaceIndex); String normalizedCommand = normalizeBotCommand(commandToken); @@ -589,7 +645,8 @@ protected TelegramCommand mapToTelegramTextCommand(Update update) { userText = enrichWithReplyContext(userText, message.getReplyToMessage(), telegramUser.getLanguageCode()); TelegramCommand cmd = new TelegramCommand(userId, message.getChatId(), telegramCommandType, update, userText, true); cmd.forwardedFrom(forwardInfo); - return cmd.languageCode(telegramUser.getLanguageCode()); + cmd.settingsOwner(settingsOwner); + return cmd.languageCode(resolveLanguageCode(settingsOwner, telegramUser)); } /** @@ -599,6 +656,7 @@ public TelegramCommand mapToTelegramPhotoCommand(Update update) { var message = update.getMessage(); TelegramUser telegramUser = userService.getOrCreateUser(message.getFrom()); Long userId = telegramUser.getId(); + User settingsOwner = resolveSettingsOwner(message.getChat(), message.getFrom(), telegramUser); String forwardInfo = extractForwardInfo(message); String caption = message.getCaption(); @@ -628,12 +686,14 @@ public TelegramCommand mapToTelegramPhotoCommand(Update update) { : " [Photo upload error: " + e.getMessage() + "]"; TelegramCommand errCmd = new TelegramCommand(userId, message.getChatId(), telegramCommandType, update, userText + errSuffix, true, new ArrayList<>()); - return errCmd.languageCode(telegramUser.getLanguageCode()); + errCmd.settingsOwner(settingsOwner); + return errCmd.languageCode(resolveLanguageCode(settingsOwner, telegramUser)); } TelegramCommand cmd = new TelegramCommand(userId, message.getChatId(), telegramCommandType, update, userText, true, attachments); cmd.forwardedFrom(forwardInfo); - return cmd.languageCode(telegramUser.getLanguageCode()); + cmd.settingsOwner(settingsOwner); + return cmd.languageCode(resolveLanguageCode(settingsOwner, telegramUser)); } /** @@ -643,6 +703,7 @@ public TelegramCommand mapToTelegramDocumentCommand(Update update) { var message = update.getMessage(); TelegramUser telegramUser = userService.getOrCreateUser(message.getFrom()); Long userId = telegramUser.getId(); + User settingsOwner = resolveSettingsOwner(message.getChat(), message.getFrom(), telegramUser); String forwardInfo = extractForwardInfo(message); String caption = message.getCaption(); @@ -675,7 +736,8 @@ public TelegramCommand mapToTelegramDocumentCommand(Update update) { ? messageLocalizationService.getMessage("telegram.error.unsupported.file", telegramUser.getLanguageCode(), message.getDocument().getMimeType()) : " [Unsupported file type: " + message.getDocument().getMimeType() + "]"; TelegramCommand errCmd = new TelegramCommand(userId, message.getChatId(), telegramCommandType, update, userText + errSuffix, true, new ArrayList<>()); - return errCmd.languageCode(telegramUser.getLanguageCode()); + errCmd.settingsOwner(settingsOwner); + return errCmd.languageCode(resolveLanguageCode(settingsOwner, telegramUser)); } } catch (Exception e) { log.error("Error processing document for user {}", userId, e); @@ -683,7 +745,8 @@ public TelegramCommand mapToTelegramDocumentCommand(Update update) { ? messageLocalizationService.getMessage("telegram.error.document.load", telegramUser.getLanguageCode(), e.getMessage()) : " [Document upload error: " + e.getMessage() + "]"; TelegramCommand errCmd = new TelegramCommand(userId, message.getChatId(), telegramCommandType, update, userText + errSuffix, true, new ArrayList<>()); - return errCmd.languageCode(telegramUser.getLanguageCode()); + errCmd.settingsOwner(settingsOwner); + return errCmd.languageCode(resolveLanguageCode(settingsOwner, telegramUser)); } Attachment first = attachments.getFirst(); @@ -691,7 +754,8 @@ public TelegramCommand mapToTelegramDocumentCommand(Update update) { attachments.size(), userText, first != null ? first.type() : null, first != null && first.data() != null ? first.data().length : 0); TelegramCommand cmd = new TelegramCommand(userId, message.getChatId(), telegramCommandType, update, userText, true, attachments); cmd.forwardedFrom(forwardInfo); - return cmd.languageCode(telegramUser.getLanguageCode()); + cmd.settingsOwner(settingsOwner); + return cmd.languageCode(resolveLanguageCode(settingsOwner, telegramUser)); } public void sendMessage(Long chatId, String text) throws TelegramApiException { diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java index 0af0636c..75628fe0 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java @@ -6,6 +6,7 @@ import org.telegram.telegrambots.meta.api.objects.Update; import io.github.ngirchev.opendaimon.common.command.IChatCommand; import io.github.ngirchev.opendaimon.common.model.Attachment; +import io.github.ngirchev.opendaimon.common.model.User; import java.util.ArrayList; import java.util.List; @@ -41,6 +42,13 @@ public class TelegramCommand implements IChatCommand { private String languageCode; /** Source description for forwarded messages (e.g. user name, channel title). Null if not forwarded. */ private String forwardedFrom; + /** + * Resolved owner of chat-scoped settings (language, preferred model, agent mode, thinking mode, + * assistant role, menu version hash). {@code TelegramUser} for private chats, + * {@code TelegramGroup} for group/supergroup chats. Populated once per update in + * {@code TelegramBot.mapToTelegram*} via {@code ChatSettingsOwnerResolver}. + */ + private User settingsOwner; public TelegramCommand(Long userId, Long chatId, TelegramCommandType telegramCommandType, Update update) { this.userId = userId; @@ -94,6 +102,29 @@ public boolean hasAttachments() { return attachments != null && !attachments.isEmpty(); } + /** + * Returns the settings owner or the given fallback when not populated. Fallback path is used + * by legacy unit tests that construct commands without going through + * {@code TelegramBot.mapToTelegram*}; production call-sites always see a non-null owner. + */ + public User settingsOwnerOr(User fallback) { + User resolved = settingsOwner; + return resolved != null ? resolved : fallback; + } + + /** + * Null-safe owner resolver for call-sites that may receive a mocked command + * (Mockito returns {@code null} from non-stubbed methods). Reads the + * {@code settingsOwner} getter directly — whatever that getter returns + * (real field or Mockito default {@code null}) is what the caller sees, + * with the given {@code fallback} applied if it is {@code null}. + */ + public static User resolveOwner(TelegramCommand command, User fallback) { + if (command == null) return fallback; + User owner = command.settingsOwner(); + return owner != null ? owner : fallback; + } + /** * Adds an attachment to the command. */ diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandler.java index 5abfafa8..f13d7162 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandler.java @@ -10,6 +10,7 @@ import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; import io.github.ngirchev.opendaimon.common.command.ICommand; +import io.github.ngirchev.opendaimon.common.model.User; import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; @@ -17,6 +18,7 @@ import io.github.ngirchev.opendaimon.telegram.command.handler.AbstractTelegramCommandHandlerWithResponseSend; import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramCommandHandlerException; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import io.github.ngirchev.opendaimon.telegram.service.TelegramBotMenuService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; @@ -36,15 +38,18 @@ public class LanguageTelegramCommandHandler extends AbstractTelegramCommandHandl private final TelegramUserService telegramUserService; private final TelegramBotMenuService menuService; + private final ChatSettingsService chatSettingsService; public LanguageTelegramCommandHandler(ObjectProvider telegramBotProvider, TypingIndicatorService typingIndicatorService, MessageLocalizationService messageLocalizationService, TelegramUserService telegramUserService, - TelegramBotMenuService menuService) { + TelegramBotMenuService menuService, + ChatSettingsService chatSettingsService) { super(telegramBotProvider, typingIndicatorService, messageLocalizationService); this.telegramUserService = telegramUserService; this.menuService = menuService; + this.chatSettingsService = chatSettingsService; } @Override @@ -83,7 +88,8 @@ public String handleInner(TelegramCommand command) { throw new TelegramCommandHandlerException(command.telegramId(), "Message is required for language command"); } TelegramUser user = telegramUserService.getOrCreateUser(message.getFrom()); - String currentLang = user.getLanguageCode() != null ? user.getLanguageCode() : DEFAULT_LANGUAGE; + User owner = TelegramCommand.resolveOwner(command,user); + String currentLang = owner.getLanguageCode() != null ? owner.getLanguageCode() : DEFAULT_LANGUAGE; String currentLabel = languageLabel(currentLang, command.languageCode()); String currentMsg = messageLocalizationService.getMessage("telegram.language.current", command.languageCode(), currentLabel); sendLanguageMenu(command.telegramId(), command.languageCode(), currentMsg); @@ -112,7 +118,8 @@ private void handleCallbackQuery(TelegramCommand command) { sendErrorMessage(command.telegramId(), messageLocalizationService.getMessage("telegram.language.unknown", command.languageCode())); return; } - telegramUserService.updateLanguageCode(cq.getFrom().getId(), normalized); + User owner = TelegramCommand.resolveOwner(command,telegramUserService.getOrCreateUser(cq.getFrom())); + chatSettingsService.updateLanguageCode(owner, normalized); menuService.setupBotMenuForUser(command.telegramId(), normalized); String label = languageLabel(normalized, normalized); String updatedMsg = messageLocalizationService.getMessage("telegram.language.updated", normalized, label); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandler.java index 9eb6ed4d..68c49dcf 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandler.java @@ -109,19 +109,25 @@ public String handleInner(TelegramCommand command) { // --- Success response sending --- private void sendSuccessResponse(MessageHandlerContext ctx, TelegramCommand command, Message message) { + // ownerId identifies the settings-owner row (group in group chats, user in privates) + // so the keyboard label reads the group's preferred model / recent state, not the + // invoker's private-chat state. Falls back to invoker when settingsOwner is unset + // (legacy paths without a resolver). + Long ownerId = io.github.ngirchev.opendaimon.telegram.command.TelegramCommand + .resolveOwner(command, ctx.getTelegramUser()).getId(); if (ctx.isAlreadySentInStream()) { // Streaming: text already sent paragraph-by-paragraph, now send keyboard persistentKeyboardService.sendKeyboard( - command.telegramId(), ctx.getTelegramUser().getId(), + command.telegramId(), ownerId, ctx.getThread(), ctx.getResponseModel()); } else { // Non-streaming: send text + keyboard, then status message with model name String htmlText = AIUtils.convertMarkdownToHtml(ctx.getResponseText().orElseThrow()); ReplyKeyboardMarkup keyboard = persistentKeyboardService.buildKeyboardMarkup( - ctx.getTelegramUser().getId(), ctx.getThread()); + ownerId, ctx.getThread()); sendMessage(command.telegramId(), htmlText, message.getMessageId(), keyboard); persistentKeyboardService.sendKeyboard( - command.telegramId(), ctx.getTelegramUser().getId(), + command.telegramId(), ownerId, ctx.getThread(), ctx.getResponseModel()); } } diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java index ee6c427a..f75699b0 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandler.java @@ -10,6 +10,7 @@ import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; import io.github.ngirchev.opendaimon.common.command.ICommand; +import io.github.ngirchev.opendaimon.common.model.User; import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; @@ -17,6 +18,7 @@ import io.github.ngirchev.opendaimon.telegram.command.handler.AbstractTelegramCommandHandlerWithResponseSend; import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramCommandHandlerException; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; @@ -31,13 +33,16 @@ public class ModeTelegramCommandHandler extends AbstractTelegramCommandHandlerWi private static final String CALLBACK_REGULAR = CALLBACK_PREFIX + "REGULAR"; private final TelegramUserService telegramUserService; + private final ChatSettingsService chatSettingsService; public ModeTelegramCommandHandler(ObjectProvider telegramBotProvider, TypingIndicatorService typingIndicatorService, MessageLocalizationService messageLocalizationService, - TelegramUserService telegramUserService) { + TelegramUserService telegramUserService, + ChatSettingsService chatSettingsService) { super(telegramBotProvider, typingIndicatorService, messageLocalizationService); this.telegramUserService = telegramUserService; + this.chatSettingsService = chatSettingsService; } @Override @@ -76,7 +81,8 @@ public String handleInner(TelegramCommand command) { throw new TelegramCommandHandlerException(command.telegramId(), "Message is required for mode command"); } TelegramUser user = telegramUserService.getOrCreateUser(message.getFrom()); - Boolean currentMode = user.getAgentModeEnabled(); + User owner = TelegramCommand.resolveOwner(command,user); + Boolean currentMode = owner.getAgentModeEnabled(); String currentLabel = modeLabel(currentMode, command.languageCode()); String currentMsg = messageLocalizationService.getMessage("telegram.mode.current", command.languageCode(), currentLabel); sendModeMenu(command.telegramId(), command.languageCode(), currentMsg); @@ -94,8 +100,9 @@ private void handleCallbackQuery(TelegramCommand command) { deleteMenuMessage(command.telegramId(), cq); return; } + User owner = TelegramCommand.resolveOwner(command,telegramUserService.getOrCreateUser(cq.getFrom())); if (CALLBACK_AGENT.equals(callbackData)) { - telegramUserService.updateAgentMode(cq.getFrom().getId(), true); + chatSettingsService.updateAgentMode(owner, true); String label = messageLocalizationService.getMessage("telegram.mode.label.agent", command.languageCode()); String updatedMsg = messageLocalizationService.getMessage("telegram.mode.updated", command.languageCode(), label); ackCallback(cq.getId(), updatedMsg); @@ -104,7 +111,7 @@ private void handleCallbackQuery(TelegramCommand command) { return; } if (CALLBACK_REGULAR.equals(callbackData)) { - telegramUserService.updateAgentMode(cq.getFrom().getId(), false); + chatSettingsService.updateAgentMode(owner, false); String label = messageLocalizationService.getMessage("telegram.mode.label.regular", command.languageCode()); String updatedMsg = messageLocalizationService.getMessage("telegram.mode.updated", command.languageCode(), label); ackCallback(cq.getId(), updatedMsg); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandler.java index f0b24c57..6ac154ba 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandler.java @@ -10,6 +10,7 @@ import io.github.ngirchev.opendaimon.common.command.ICommand; import io.github.ngirchev.opendaimon.common.model.ConversationThread; import io.github.ngirchev.opendaimon.common.model.ThreadScopeKind; +import io.github.ngirchev.opendaimon.common.model.User; import io.github.ngirchev.opendaimon.common.service.ConversationThreadService; import io.github.ngirchev.opendaimon.common.service.AIGateway; import io.github.ngirchev.opendaimon.common.service.AIGatewayRegistry; @@ -20,11 +21,11 @@ import io.github.ngirchev.opendaimon.telegram.command.handler.AbstractTelegramCommandHandlerWithResponseSend; import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramCommandHandlerException; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import io.github.ngirchev.opendaimon.telegram.service.ModelSelectionSession; import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; -import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; import io.github.ngirchev.opendaimon.telegram.service.UserRecentModelService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; @@ -68,7 +69,7 @@ public class ModelTelegramCommandHandler extends AbstractTelegramCommandHandlerW ); private final TelegramUserService telegramUserService; - private final UserModelPreferenceService userModelPreferenceService; + private final ChatSettingsService chatSettingsService; private final AIGatewayRegistry aiGatewayRegistry; private final IUserPriorityService userPriorityService; private final PersistentKeyboardService persistentKeyboardService; @@ -87,7 +88,7 @@ public ModelTelegramCommandHandler(ObjectProvider telegramBotProvid TypingIndicatorService typingIndicatorService, MessageLocalizationService messageLocalizationService, TelegramUserService telegramUserService, - UserModelPreferenceService userModelPreferenceService, + ChatSettingsService chatSettingsService, AIGatewayRegistry aiGatewayRegistry, IUserPriorityService userPriorityService, PersistentKeyboardService persistentKeyboardService, @@ -96,7 +97,7 @@ public ModelTelegramCommandHandler(ObjectProvider telegramBotProvid UserRecentModelService userRecentModelService) { super(telegramBotProvider, typingIndicatorService, messageLocalizationService); this.telegramUserService = telegramUserService; - this.userModelPreferenceService = userModelPreferenceService; + this.chatSettingsService = chatSettingsService; this.aiGatewayRegistry = aiGatewayRegistry; this.userPriorityService = userPriorityService; this.persistentKeyboardService = persistentKeyboardService; @@ -173,28 +174,35 @@ public String handleInner(TelegramCommand command) { throw new TelegramCommandHandlerException(command.telegramId(), "Message is required for model command"); } TelegramUser user = telegramUserService.getOrCreateUser(message.getFrom()); - sendCategoryMenu(command.telegramId(), user); + User owner = TelegramCommand.resolveOwner(command, user); + sendCategoryMenu(command.telegramId(), user, owner.getId(), command.languageCode()); return null; } // ==================== Category Menu (Level 1) ==================== - private void sendCategoryMenu(Long chatId, TelegramUser user) { + /** + * @param ownerId id of the settings owner (TelegramGroup in groups, TelegramUser in + * private chats) — used as the key for per-chat recent-model lookups so + * group members see the group's recent models, not the invoker's private ones. + * @param lang language code resolved from the settings owner (populated on + * {@code command.languageCode()} in {@code TelegramBot.mapToTelegram*}). + */ + private void sendCategoryMenu(Long chatId, TelegramUser user, Long ownerId, String lang) { try { List models = fetchModels(user); if (models.isEmpty()) { sendMessage(chatId, messageLocalizationService.getMessage( - "telegram.model.unavailable", user.getLanguageCode())); + "telegram.model.unavailable", lang)); return; } - String lang = user.getLanguageCode(); if (models.size() <= PAGE_SIZE) { sendFlatModelList(chatId, models, lang); return; } - MenuContent menu = buildCategoryMenuContent(models, lang, user.getId()); + MenuContent menu = buildCategoryMenuContent(models, lang, ownerId); SendMessage msg = new SendMessage(chatId.toString(), menu.text()); msg.setReplyMarkup(menu.markup()); telegramBotProvider.getObject().execute(msg); @@ -239,16 +247,19 @@ private void sendFlatModelList(Long chatId, List models, String lang) /** * Builds category menu content reused by both send and edit flows. * Categories with an empty resolver result (e.g. {@code RECENT} for a new - * user) are omitted automatically. + * chat) are omitted automatically. {@code ownerId} is the settings-owner id + * used by dynamic categories (like RECENT) to look up chat-scoped state — + * passing the invoker's id instead would leak their private recent models + * into the group view. */ - private MenuContent buildCategoryMenuContent(List models, String lang, Long userId) { + private MenuContent buildCategoryMenuContent(List models, String lang, Long ownerId) { List> keyboard = new ArrayList<>(); keyboard.add(List.of(createButton( messageLocalizationService.getMessage("telegram.model.auto", lang), CALLBACK_AUTO))); for (ModelCategory category : categoryDefinitions) { - int count = category.resolver().resolve(models, userId).size(); + int count = category.resolver().resolve(models, ownerId).size(); if (count == 0) { continue; } @@ -266,11 +277,10 @@ private MenuContent buildCategoryMenuContent(List models, String lang // ==================== Model List within Category (Level 2) ==================== - private void showCategoryPage(Long chatId, Integer messageId, TelegramUser user, - String categoryKey, int page) { + private void showCategoryPage(Long chatId, Integer messageId, TelegramUser user, Long ownerId, + String lang, String categoryKey, int page) { try { List allModels = fetchModels(user); - String lang = user.getLanguageCode(); ModelCategory category = findCategory(categoryKey); if (category == null) { @@ -278,7 +288,7 @@ private void showCategoryPage(Long chatId, Integer messageId, TelegramUser user, return; } - List matchingIndices = category.resolver().resolve(allModels, user.getId()); + List matchingIndices = category.resolver().resolve(allModels, ownerId); if (matchingIndices.isEmpty()) { log.warn("Empty category '{}' for chat={}", categoryKey, chatId); @@ -355,6 +365,8 @@ private void handleCallbackQuery(TelegramCommand command) { TelegramUser user = telegramUserService.getOrCreateUser(cq.getFrom()); Long userId = user.getId(); + User owner = TelegramCommand.resolveOwner(command, user); + Long ownerId = owner.getId(); Integer messageId = extractMessageId(cq); // Cancel — delete, evict cache, return @@ -374,7 +386,7 @@ private void handleCallbackQuery(TelegramCommand command) { // Back to categories if (CALLBACK_BACK.equals(callbackData)) { ackCallback(cq.getId(), ""); - editToCategoryMenu(command.telegramId(), messageId, user); + editToCategoryMenu(command.telegramId(), messageId, user, ownerId, command.languageCode()); return; } @@ -395,35 +407,37 @@ private void handleCallbackQuery(TelegramCommand command) { } else { categoryKey = catPart; } - showCategoryPage(command.telegramId(), messageId, user, categoryKey, page); + showCategoryPage(command.telegramId(), messageId, user, ownerId, command.languageCode(), + categoryKey, page); return; } // Auto selection if (CALLBACK_AUTO.equals(callbackData)) { - userModelPreferenceService.clearPreference(userId); + chatSettingsService.clearPreferredModel(owner); ackCallback(cq.getId(), messageLocalizationService.getMessage( - "telegram.model.ack.auto", user.getLanguageCode())); + "telegram.model.ack.auto", command.languageCode())); deleteMenuMessage(command.telegramId(), cq); modelSelectionSession.evict(userId); - sendPersistentKeyboard(command.telegramId(), userId); + sendPersistentKeyboard(command.telegramId(), ownerId); return; } // Model selection: MODEL_ String modelName = resolveModelName(callbackData, user); - userModelPreferenceService.setPreferredModel(userId, modelName); - userRecentModelService.recordUsage(userId, modelName); + chatSettingsService.setPreferredModel(owner, modelName); + userRecentModelService.recordUsage(ownerId, modelName); ackCallback(cq.getId(), "✅ " + modelName); deleteMenuMessage(command.telegramId(), cq); modelSelectionSession.evict(userId); - sendPersistentKeyboard(command.telegramId(), userId); + sendPersistentKeyboard(command.telegramId(), ownerId); } - private void editToCategoryMenu(Long chatId, Integer messageId, TelegramUser user) { + private void editToCategoryMenu(Long chatId, Integer messageId, TelegramUser user, Long ownerId, + String lang) { try { List models = fetchModels(user); - MenuContent menu = buildCategoryMenuContent(models, user.getLanguageCode(), user.getId()); + MenuContent menu = buildCategoryMenuContent(models, lang, ownerId); editMenuMessage(chatId, messageId, menu.text(), menu.markup()); } catch (Exception e) { log.error("Failed to edit category menu: {}", e.getMessage(), e); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/NewThreadTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/NewThreadTelegramCommandHandler.java index f81b7824..560c3538 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/NewThreadTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/NewThreadTelegramCommandHandler.java @@ -67,24 +67,29 @@ public String handleInner(TelegramCommand command) throws TelegramCommandHandler } TelegramUser user = userService.getOrCreateUser(message.getFrom()); + io.github.ngirchev.opendaimon.common.model.User owner = + io.github.ngirchev.opendaimon.telegram.command.TelegramCommand.resolveOwner(command, user); Long chatId = command.telegramId(); - + // Close current thread (if any active) Optional currentThread = threadRepository.findMostRecentActiveThread( ThreadScopeKind.TELEGRAM_CHAT, chatId); boolean hadPreviousThread = currentThread.isPresent(); currentThread.ifPresent(threadService::closeThread); - - // Create new thread + + // Create new thread — thread.user is the invoker (audit), scope is per-chat. ConversationThread newThread = threadService.createNewThread(user, ThreadScopeKind.TELEGRAM_CHAT, chatId); - // Reset the context-usage button to 0% immediately + // Reset the context-usage button to 0% immediately. Keyboard label reads from the + // settings owner's row (group row in groups) so it shows the group's current model, + // not the invoker's private-chat model. PersistentKeyboardService keyboardService = persistentKeyboardServiceProvider.getIfAvailable(); if (keyboardService != null) { - keyboardService.sendKeyboard(command.telegramId(), user.getId(), newThread); + keyboardService.sendKeyboard(command.telegramId(), owner.getId(), newThread); } - String lang = user.getLanguageCode(); + // Localise the response in the owner's language (group language in groups, user in privates). + String lang = owner.getLanguageCode() != null ? owner.getLanguageCode() : user.getLanguageCode(); String threadPreview = newThread.getThreadKey().substring(0, 8) + "..."; String responseMessage = messageLocalizationService.getMessage( "telegram.newthread.body", lang, threadPreview); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/RoleTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/RoleTelegramCommandHandler.java index af66d138..5f5556de 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/RoleTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/RoleTelegramCommandHandler.java @@ -11,6 +11,7 @@ import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; import io.github.ngirchev.opendaimon.common.command.ICommand; import io.github.ngirchev.opendaimon.common.model.AssistantRole; +import io.github.ngirchev.opendaimon.common.model.User; import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType; @@ -18,6 +19,7 @@ import io.github.ngirchev.opendaimon.telegram.command.handler.AbstractTelegramCommandHandlerWithResponseSend; import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramCommandHandlerException; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; import io.github.ngirchev.opendaimon.common.config.CoreCommonProperties; @@ -35,15 +37,18 @@ public class RoleTelegramCommandHandler extends AbstractTelegramCommandHandlerWi private final TelegramUserService telegramUserService; private final CoreCommonProperties coreCommonProperties; + private final ChatSettingsService chatSettingsService; public RoleTelegramCommandHandler(ObjectProvider telegramBotProvider, TypingIndicatorService typingIndicatorService, MessageLocalizationService messageLocalizationService, TelegramUserService telegramUserService, - CoreCommonProperties coreCommonProperties) { + CoreCommonProperties coreCommonProperties, + ChatSettingsService chatSettingsService) { super(telegramBotProvider, typingIndicatorService, messageLocalizationService); this.telegramUserService = telegramUserService; this.coreCommonProperties = coreCommonProperties; + this.chatSettingsService = chatSettingsService; } @Override @@ -82,13 +87,14 @@ public String handleInner(TelegramCommand command) { throw new TelegramCommandHandlerException(command.telegramId(), "Message is required for role command"); } TelegramUser user = telegramUserService.getOrCreateUser(message.getFrom()); + User owner = TelegramCommand.resolveOwner(command,user); String userText = command.userText() != null ? command.userText().trim() : null; - + String lang = command.languageCode(); if (userText == null || userText.isEmpty()) { - // Show current role - AssistantRole currentRole = telegramUserService.getOrCreateAssistantRole( - user, + // Show current role (owner-scoped: group in groups, user in privates) + AssistantRole currentRole = chatSettingsService.getOrCreateAssistantRole( + owner, messageLocalizationService.getMessage(coreCommonProperties.getAssistantRole(), lang) ); @@ -109,8 +115,8 @@ public String handleInner(TelegramCommand command) { // Return null as messages already sent return null; } else { - // Update role - telegramUserService.updateAssistantRole(message.getFrom(), userText); + // Update role on the settings owner (group in groups, user in privates) + chatSettingsService.updateAssistantRole(owner, userText); telegramBotProvider.getObject().clearStatus(message.getFrom().getId()); // Send confirmation replying to user message @@ -152,7 +158,8 @@ private void handleCallbackQuery(TelegramCommand command) { return; } - telegramUserService.updateAssistantRole(cq.getFrom(), preset.get().content()); + User owner = TelegramCommand.resolveOwner(command,telegramUserService.getOrCreateUser(cq.getFrom())); + chatSettingsService.updateAssistantRole(owner, preset.get().content()); telegramBotProvider.getObject().clearStatus(cq.getFrom().getId()); ackCallback(cq.getId(), messageLocalizationService.getMessage("telegram.role.ack.updated", lang)); deleteMenuMessage(command.telegramId(), cq); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandler.java index cf2111f2..5d9f3713 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandler.java @@ -11,6 +11,7 @@ import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; import io.github.ngirchev.opendaimon.common.command.ICommand; import io.github.ngirchev.opendaimon.common.model.ThinkingMode; +import io.github.ngirchev.opendaimon.common.model.User; import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; @@ -18,6 +19,7 @@ import io.github.ngirchev.opendaimon.telegram.command.handler.AbstractTelegramCommandHandlerWithResponseSend; import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramCommandHandlerException; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import io.github.ngirchev.opendaimon.telegram.service.TelegramBotMenuService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; @@ -35,15 +37,18 @@ public class ThinkingTelegramCommandHandler extends AbstractTelegramCommandHandl private final TelegramUserService telegramUserService; private final TelegramBotMenuService menuService; + private final ChatSettingsService chatSettingsService; public ThinkingTelegramCommandHandler(ObjectProvider telegramBotProvider, TypingIndicatorService typingIndicatorService, MessageLocalizationService messageLocalizationService, TelegramUserService telegramUserService, - TelegramBotMenuService menuService) { + TelegramBotMenuService menuService, + ChatSettingsService chatSettingsService) { super(telegramBotProvider, typingIndicatorService, messageLocalizationService); this.telegramUserService = telegramUserService; this.menuService = menuService; + this.chatSettingsService = chatSettingsService; } @Override @@ -82,7 +87,8 @@ public String handleInner(TelegramCommand command) { throw new TelegramCommandHandlerException(command.telegramId(), "Message is required for thinking command"); } TelegramUser user = telegramUserService.getOrCreateUser(message.getFrom()); - ThinkingMode currentMode = user.getThinkingMode() != null ? user.getThinkingMode() : ThinkingMode.HIDE_REASONING; + User owner = TelegramCommand.resolveOwner(command,user); + ThinkingMode currentMode = owner.getThinkingMode() != null ? owner.getThinkingMode() : ThinkingMode.HIDE_REASONING; String currentLabel = thinkingModeLabel(currentMode, command.languageCode()); String currentMsg = messageLocalizationService.getMessage("telegram.thinking.current", command.languageCode(), currentLabel); sendThinkingMenu(command.telegramId(), command.languageCode(), currentMsg); @@ -102,8 +108,9 @@ private void handleCallbackQuery(TelegramCommand command) { deleteMenuMessage(command.telegramId(), cq); return; } + User owner = TelegramCommand.resolveOwner(command,telegramUserService.getOrCreateUser(cq.getFrom())); if (CALLBACK_SHOW_ALL.equals(callbackData)) { - telegramUserService.updateThinkingMode(cq.getFrom().getId(), ThinkingMode.SHOW_ALL); + chatSettingsService.updateThinkingMode(owner, ThinkingMode.SHOW_ALL); String label = messageLocalizationService.getMessage("telegram.thinking.label.show_all", command.languageCode()); String updatedMsg = messageLocalizationService.getMessage("telegram.thinking.updated", command.languageCode(), label); ackCallback(cq.getId(), updatedMsg); @@ -112,7 +119,7 @@ private void handleCallbackQuery(TelegramCommand command) { return; } if (CALLBACK_HIDE_REASONING.equals(callbackData)) { - telegramUserService.updateThinkingMode(cq.getFrom().getId(), ThinkingMode.HIDE_REASONING); + chatSettingsService.updateThinkingMode(owner, ThinkingMode.HIDE_REASONING); String label = messageLocalizationService.getMessage("telegram.thinking.label.tools_only", command.languageCode()); String updatedMsg = messageLocalizationService.getMessage("telegram.thinking.updated", command.languageCode(), label); ackCallback(cq.getId(), updatedMsg); @@ -121,7 +128,7 @@ private void handleCallbackQuery(TelegramCommand command) { return; } if (CALLBACK_SILENT.equals(callbackData)) { - telegramUserService.updateThinkingMode(cq.getFrom().getId(), ThinkingMode.SILENT); + chatSettingsService.updateThinkingMode(owner, ThinkingMode.SILENT); String label = messageLocalizationService.getMessage("telegram.thinking.label.silent", command.languageCode()); String updatedMsg = messageLocalizationService.getMessage("telegram.thinking.updated", command.languageCode(), label); ackCallback(cq.getId(), updatedMsg); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java index 59bfc30c..072ac924 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java @@ -39,7 +39,8 @@ import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; import io.github.ngirchev.opendaimon.telegram.service.ToolLabels; -import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.model.ChatResponse; @@ -139,7 +140,7 @@ private static int maxLength(String[] arr) { private final OpenDaimonMessageService messageService; private final AIRequestPipeline aiRequestPipeline; private final TelegramProperties telegramProperties; - private final UserModelPreferenceService userModelPreferenceService; + private final ChatSettingsService chatSettingsService; private final PersistentKeyboardService persistentKeyboardService; private final ReplyImageAttachmentService replyImageAttachmentService; @@ -243,10 +244,13 @@ public void prepareMetadata(MessageHandlerContext ctx) { metadata.put(ASSISTANT_ROLE_ID_FIELD, ctx.getAssistantRole().getId().toString()); metadata.put(USER_ID_FIELD, telegramUser.getId().toString()); metadata.put(ROLE_FIELD, withTelegramBotIdentity(ctx.getAssistantRole().getContent())); - if (telegramUser.getLanguageCode() != null) { - metadata.put(LANGUAGE_CODE_FIELD, telegramUser.getLanguageCode()); + User settingsOwner = resolveOwner(ctx, telegramUser); + String ownerLanguage = settingsOwner.getLanguageCode() != null + ? settingsOwner.getLanguageCode() : telegramUser.getLanguageCode(); + if (ownerLanguage != null) { + metadata.put(LANGUAGE_CODE_FIELD, ownerLanguage); } - userModelPreferenceService.getPreferredModel(telegramUser.getId()) + chatSettingsService.getPreferredModel(settingsOwner) .ifPresent(modelId -> metadata.put(PREFERRED_MODEL_ID_FIELD, modelId)); // Add RAG document IDs from previous turns @@ -316,10 +320,23 @@ private boolean isAgentModeEnabledForUser(MessageHandlerContext ctx) { if (user == null) { return defaultAgentModeEnabled; } - Boolean flag = user.getAgentModeEnabled(); + User owner = resolveOwner(ctx, user); + Boolean flag = owner.getAgentModeEnabled(); return flag != null ? flag : defaultAgentModeEnabled; } + /** + * Safe owner resolution: returns {@code ctx.getCommand().settingsOwnerOr(fallback)} if the + * command exposes a non-null owner, otherwise falls back to {@code fallback} (the invoker). + * Guards against test mocks that return {@code null} from {@code settingsOwnerOr}. + */ + private static User resolveOwner(MessageHandlerContext ctx, TelegramUser fallback) { + TelegramCommand cmd = ctx.getCommand(); + if (cmd == null) return fallback; + User owner = cmd.settingsOwnerOr(fallback); + return owner != null ? owner : fallback; + } + private void generateAgentResponse(MessageHandlerContext ctx) { TelegramCommand command = ctx.getCommand(); Map metadata = ctx.getMetadata(); @@ -689,7 +706,11 @@ private Mono applyUpdate(MessageHandlerContext ctx, RenderedUpdate update) */ private boolean isThinkingSilent(MessageHandlerContext ctx) { TelegramUser user = ctx.getTelegramUser(); - return user != null && user.getThinkingMode() == ThinkingMode.SILENT; + if (user == null) { + return false; + } + User owner = resolveOwner(ctx, user); + return owner.getThinkingMode() == ThinkingMode.SILENT; } private void ensureStatusMessage(MessageHandlerContext ctx) { @@ -698,10 +719,11 @@ private void ensureStatusMessage(MessageHandlerContext ctx) { } Long chatId = ctx.getCommand().telegramId(); TelegramUser user = ctx.getTelegramUser(); - boolean silent = user != null && user.getThinkingMode() == ThinkingMode.SILENT; + User owner = user != null ? resolveOwner(ctx, user) : null; + boolean silent = owner != null && owner.getThinkingMode() == ThinkingMode.SILENT; log.info("ensureStatusMessage: telegramId={}, thinkingMode={}, silent={}", user != null ? user.getTelegramId() : null, - user != null ? user.getThinkingMode() : "null-user", + owner != null ? owner.getThinkingMode() : "null-owner", silent); // SILENT: do NOT create a status message at all. The user's intent is radical // silence — no thinking placeholder, no tool blocks, no observations in a @@ -774,10 +796,11 @@ private Mono appendToolCallBlock(MessageHandlerContext ctx, String toolNam // kept above the tool-call block so the user can read // "model thought → called that tool" in the final message. TelegramUser user = ctx.getTelegramUser(); - boolean preserve = user != null && user.getThinkingMode() == ThinkingMode.SHOW_ALL; + User preserveOwner = user != null ? resolveOwner(ctx, user) : null; + boolean preserve = preserveOwner != null && preserveOwner.getThinkingMode() == ThinkingMode.SHOW_ALL; log.info("appendToolCallBlock: telegramId={}, thinkingMode={}, preserveReasoningAbove={}", user != null ? user.getTelegramId() : null, - user != null ? user.getThinkingMode() : "null-user", + preserveOwner != null ? preserveOwner.getThinkingMode() : "null-owner", preserve); StringBuilder buf = ctx.getStatusBuffer(); int lastBoundary = buf.lastIndexOf("\n\n"); @@ -1100,7 +1123,8 @@ private void generateGatewayResponse(MessageHandlerContext ctx) { e.getModelId()); messageSender.sendNotification(command.telegramId(), "common.error.model.guardrail", command.languageCode(), e.getModelId()); - userModelPreferenceService.clearPreference(ctx.getTelegramUser().getId()); + User guardrailOwner = resolveOwner(ctx, ctx.getTelegramUser()); + chatSettingsService.clearPreferredModel(guardrailOwner); Map metadata = aiCommand.metadata(); metadata.remove(PREFERRED_MODEL_ID_FIELD); aiCommand = aiRequestPipeline.prepareCommand(command, metadata); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java index c981f6e6..5890f174 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java @@ -14,6 +14,7 @@ import io.github.ngirchev.opendaimon.bulkhead.config.BulkHeadAutoConfig; import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; import io.github.ngirchev.opendaimon.telegram.TelegramBot; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsOwnerResolver; import io.github.ngirchev.opendaimon.telegram.service.TelegramBotMenuService; import io.github.ngirchev.opendaimon.telegram.service.TelegramBotRegistrar; import io.github.ngirchev.opendaimon.telegram.service.TelegramCommandSyncService; @@ -48,7 +49,8 @@ public TelegramBot telegramBot(TelegramProperties properties, ObjectProvider fileServiceProvider, ObjectProvider fileUploadPropertiesProvider, ObjectProvider messageCoalescingServiceProvider, - ObjectProvider menuServiceProvider) { + ObjectProvider menuServiceProvider, + ObjectProvider ownerResolverProvider) { Integer socketTimeoutSec = properties.getLongPollingSocketTimeoutSeconds(); Integer getUpdatesTimeoutSec = properties.getGetUpdatesTimeoutSeconds(); DefaultBotOptions options = new DefaultBotOptions(); @@ -64,7 +66,7 @@ public TelegramBot telegramBot(TelegramProperties properties, } return new TelegramBot(properties, options, commandSyncService, userService, messageLocalizationService, fileServiceProvider, fileUploadPropertiesProvider, - messageCoalescingServiceProvider, menuServiceProvider); + messageCoalescingServiceProvider, menuServiceProvider, ownerResolverProvider); } @Bean diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java index dccf3ad8..9d230f6f 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java @@ -27,6 +27,7 @@ import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerState; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageHandlerActions; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageSender; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import io.github.ngirchev.opendaimon.telegram.service.InMemoryModelSelectionSession; import io.github.ngirchev.opendaimon.telegram.service.ModelSelectionSession; import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamRenderer; @@ -88,9 +89,11 @@ public RoleTelegramCommandHandler roleTelegramCommandHandler( TypingIndicatorService typingIndicatorService, MessageLocalizationService messageLocalizationService, TelegramUserService telegramUserService, - CoreCommonProperties coreCommonProperties) { + CoreCommonProperties coreCommonProperties, + ChatSettingsService chatSettingsService) { return new RoleTelegramCommandHandler(telegramBotProvider, - typingIndicatorService, messageLocalizationService, telegramUserService, coreCommonProperties); + typingIndicatorService, messageLocalizationService, telegramUserService, coreCommonProperties, + chatSettingsService); } @Bean @@ -101,9 +104,11 @@ public LanguageTelegramCommandHandler languageTelegramCommandHandler( TypingIndicatorService typingIndicatorService, MessageLocalizationService messageLocalizationService, TelegramUserService telegramUserService, - TelegramBotMenuService telegramBotMenuService) { + TelegramBotMenuService telegramBotMenuService, + ChatSettingsService chatSettingsService) { return new LanguageTelegramCommandHandler(telegramBotProvider, - typingIndicatorService, messageLocalizationService, telegramUserService, telegramBotMenuService); + typingIndicatorService, messageLocalizationService, telegramUserService, telegramBotMenuService, + chatSettingsService); } @Bean @@ -114,9 +119,10 @@ public ModeTelegramCommandHandler modeTelegramCommandHandler( ObjectProvider telegramBotProvider, TypingIndicatorService typingIndicatorService, MessageLocalizationService messageLocalizationService, - TelegramUserService telegramUserService) { + TelegramUserService telegramUserService, + ChatSettingsService chatSettingsService) { return new ModeTelegramCommandHandler(telegramBotProvider, - typingIndicatorService, messageLocalizationService, telegramUserService); + typingIndicatorService, messageLocalizationService, telegramUserService, chatSettingsService); } @Bean @@ -127,9 +133,11 @@ public ThinkingTelegramCommandHandler thinkingTelegramCommandHandler( TypingIndicatorService typingIndicatorService, MessageLocalizationService messageLocalizationService, TelegramUserService telegramUserService, - TelegramBotMenuService telegramBotMenuService) { + TelegramBotMenuService telegramBotMenuService, + ChatSettingsService chatSettingsService) { return new ThinkingTelegramCommandHandler(telegramBotProvider, - typingIndicatorService, messageLocalizationService, telegramUserService, telegramBotMenuService); + typingIndicatorService, messageLocalizationService, telegramUserService, telegramBotMenuService, + chatSettingsService); } @Bean @@ -228,7 +236,7 @@ public TelegramMessageHandlerActions messageHandlerActions( OpenDaimonMessageService messageService, AIRequestPipeline aiRequestPipeline, TelegramProperties telegramProperties, - UserModelPreferenceService userModelPreferenceService, + ChatSettingsService chatSettingsService, PersistentKeyboardService persistentKeyboardService, ReplyImageAttachmentService replyImageAttachmentService, TelegramMessageSender telegramMessageSender, @@ -240,7 +248,7 @@ public TelegramMessageHandlerActions messageHandlerActions( return new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, - aiRequestPipeline, telegramProperties, userModelPreferenceService, + aiRequestPipeline, telegramProperties, chatSettingsService, persistentKeyboardService, replyImageAttachmentService, telegramMessageSender, agentExecutorProvider.getIfAvailable(), agentStreamRenderer, agentMaxIterations, defaultAgentModeEnabled); @@ -287,14 +295,13 @@ public UserModelPreferenceService userModelPreferenceService( @ConditionalOnMissingBean @ConditionalOnProperty(prefix = FeatureToggle.TelegramCommand.PREFIX, name = FeatureToggle.TelegramCommand.MODEL, havingValue = "true", matchIfMissing = true) public PersistentKeyboardService persistentKeyboardService( - UserModelPreferenceService userModelPreferenceService, CoreCommonProperties coreCommonProperties, ObjectProvider telegramBotProvider, TelegramProperties telegramProperties, MessageLocalizationService messageLocalizationService, - TelegramUserRepository telegramUserRepository) { - return new PersistentKeyboardService(userModelPreferenceService, coreCommonProperties, telegramBotProvider, - telegramProperties, messageLocalizationService, telegramUserRepository); + io.github.ngirchev.opendaimon.common.repository.UserRepository userRepository) { + return new PersistentKeyboardService(coreCommonProperties, telegramBotProvider, + telegramProperties, messageLocalizationService, userRepository); } @Bean @@ -311,7 +318,7 @@ public ModelTelegramCommandHandler modelTelegramCommandHandler( TypingIndicatorService typingIndicatorService, MessageLocalizationService messageLocalizationService, TelegramUserService telegramUserService, - UserModelPreferenceService userModelPreferenceService, + ChatSettingsService chatSettingsService, AIGatewayRegistry aiGatewayRegistry, IUserPriorityService userPriorityService, PersistentKeyboardService persistentKeyboardService, @@ -323,7 +330,7 @@ public ModelTelegramCommandHandler modelTelegramCommandHandler( typingIndicatorService, messageLocalizationService, telegramUserService, - userModelPreferenceService, + chatSettingsService, aiGatewayRegistry, userPriorityService, persistentKeyboardService, diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java index 207d724b..9c306a54 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramServiceConfig.java @@ -18,6 +18,7 @@ import io.github.ngirchev.opendaimon.common.repository.UserRecentModelRepository; import io.github.ngirchev.opendaimon.common.repository.UserRepository; import io.github.ngirchev.opendaimon.common.service.AssistantRoleService; +import io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup; import io.github.ngirchev.opendaimon.common.service.ConversationThreadService; import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; import io.github.ngirchev.opendaimon.common.service.OpenDaimonMessageService; @@ -25,11 +26,13 @@ import io.github.ngirchev.opendaimon.common.storage.service.FileStorageService; import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramSupportedCommandProvider; +import io.github.ngirchev.opendaimon.telegram.repository.TelegramGroupRepository; import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserRepository; import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserSessionRepository; import io.github.ngirchev.opendaimon.telegram.repository.TelegramWhitelistRepository; import io.github.ngirchev.opendaimon.telegram.service.*; import io.github.ngirchev.opendaimon.telegram.service.impl.UserRecentModelServiceImpl; +import org.springframework.context.annotation.Primary; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -49,6 +52,37 @@ public TelegramUserService telegramUserService( defaultAgentModeEnabled); } + @Bean + @ConditionalOnMissingBean + public TelegramGroupService telegramGroupService( + TelegramGroupRepository telegramGroupRepository, + AssistantRoleService assistantRoleService, + @Value("${open-daimon.agent.enabled:false}") boolean defaultAgentModeEnabled) { + return new TelegramGroupService(telegramGroupRepository, assistantRoleService, defaultAgentModeEnabled); + } + + @Bean + @ConditionalOnMissingBean + public ChatSettingsService chatSettingsService( + TelegramUserService telegramUserService, + TelegramGroupService telegramGroupService) { + return new ChatSettingsService(telegramUserService, telegramGroupService); + } + + @Bean + @ConditionalOnMissingBean + public ChatSettingsOwnerResolver chatSettingsOwnerResolver( + TelegramUserService telegramUserService, + TelegramGroupService telegramGroupService) { + return new ChatSettingsOwnerResolver(telegramUserService, telegramGroupService); + } + + @Bean + @Primary + public ChatOwnerLookup telegramChatOwnerLookup(ChatSettingsOwnerResolver resolver) { + return new TelegramChatOwnerLookup(resolver); + } + @Bean @ConditionalOnMissingBean public TelegramUsersStartupInitializer telegramUsersStartupInitializer( @@ -97,7 +131,9 @@ public TelegramMessageService telegramMessageService( MessageLocalizationService messageLocalizationService, ObjectProvider storagePropertiesProvider, ConversationThreadService conversationThreadService, - ObjectProvider telegramMessageServiceSelfProvider) { + ObjectProvider telegramMessageServiceSelfProvider, + ChatOwnerLookup chatOwnerLookup, + ChatSettingsService chatSettingsService) { return new TelegramMessageService( messageService, telegramUserService, @@ -105,7 +141,9 @@ public TelegramMessageService telegramMessageService( messageLocalizationService, storagePropertiesProvider, conversationThreadService, - telegramMessageServiceSelfProvider); + telegramMessageServiceSelfProvider, + chatOwnerLookup, + chatSettingsService); } @Bean @@ -153,8 +191,9 @@ public TelegramMessageCoalescingService telegramMessageCoalescingService( @ConditionalOnMissingBean public TelegramBotMenuService telegramBotMenuService( ObjectProvider telegramBotProvider, - ObjectProvider commandHandlersProvider) { - return new TelegramBotMenuService(telegramBotProvider, commandHandlersProvider); + ObjectProvider commandHandlersProvider, + ObjectProvider chatSettingsServiceProvider) { + return new TelegramBotMenuService(telegramBotProvider, commandHandlersProvider, chatSettingsServiceProvider); } @Bean diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsOwnerResolver.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsOwnerResolver.java new file mode 100644 index 00000000..3a73b5cd --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsOwnerResolver.java @@ -0,0 +1,65 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import lombok.RequiredArgsConstructor; +import org.telegram.telegrambots.meta.api.objects.Chat; +import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.telegram.model.TelegramGroup; + +import java.util.Optional; + +/** + * Resolves the {@link User} that owns per-chat settings (language, preferred + * model, agent mode, thinking mode, assistant role, menu version hash) for a + * given Telegram update. + *

    + *
  • Private chat → the invoker's {@code TelegramUser}.
  • + *
  • Group or supergroup → the {@link TelegramGroup} row keyed on {@code chat_id}.
  • + *
+ * Must be called once per incoming update — the result is cached on + * {@code TelegramCommand.settingsOwner} for the duration of handler execution. + */ +@RequiredArgsConstructor +public class ChatSettingsOwnerResolver { + + private static final String GROUP = "group"; + private static final String SUPERGROUP = "supergroup"; + + private final TelegramUserService telegramUserService; + private final TelegramGroupService telegramGroupService; + + /** + * Resolves the settings owner for an incoming update. + * + * @param chat the chat the update originated in (never {@code null} for valid updates) + * @param invoker the Telegram API user who produced the update (never {@code null}) + * @return group entity for group chats, user entity for private chats + */ + public User resolveForChat(Chat chat, org.telegram.telegrambots.meta.api.objects.User invoker) { + if (chat != null && isGroupLike(chat.getType())) { + return telegramGroupService.getOrCreateGroup(chat); + } + return telegramUserService.getOrCreateUser(invoker); + } + + /** + * Looks up the settings owner by Telegram {@code chat_id} without creating + * anything. Used by common-module paths (e.g. summarization) that only have + * a chat id from a persisted {@code ConversationThread}. + *

+ * Group chat ids are negative, user chat ids are positive — we try the + * matching table first to keep this cheap. + */ + public Optional findByChatId(Long chatId) { + if (chatId == null) { + return Optional.empty(); + } + if (chatId < 0) { + return telegramGroupService.findByChatId(chatId).map(User.class::cast); + } + return telegramUserService.findByTelegramId(chatId).map(User.class::cast); + } + + private static boolean isGroupLike(String chatType) { + return GROUP.equalsIgnoreCase(chatType) || SUPERGROUP.equalsIgnoreCase(chatType); + } +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsService.java new file mode 100644 index 00000000..86314be1 --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsService.java @@ -0,0 +1,140 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import io.github.ngirchev.opendaimon.common.model.AssistantRole; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; +import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.telegram.model.TelegramGroup; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; + +import java.util.Optional; + +/** + * Polymorphic facade over per-chat settings mutations. Accepts a {@link User} + * owner resolved by {@link ChatSettingsOwnerResolver} — a {@link TelegramUser} + * for private chats, a {@link TelegramGroup} for group/supergroup chats — + * and dispatches to the corresponding service. + *

+ * Call-sites must use this facade instead of keying on + * {@code cq.getFrom().getId()} or {@code user.getTelegramId()}; that keeps + * group chats' settings consistent across members. + */ +@Slf4j +@RequiredArgsConstructor +public class ChatSettingsService { + + private final TelegramUserService telegramUserService; + private final TelegramGroupService telegramGroupService; + + public void updateLanguageCode(User owner, String languageCode) { + if (owner instanceof TelegramGroup group) { + telegramGroupService.updateLanguageCode(group.getTelegramId(), languageCode); + } else if (owner instanceof TelegramUser user) { + telegramUserService.updateLanguageCode(user.getTelegramId(), languageCode); + } else { + throw unsupported(owner, "updateLanguageCode"); + } + } + + public void updateAgentMode(User owner, boolean enabled) { + if (owner instanceof TelegramGroup group) { + telegramGroupService.updateAgentMode(group.getTelegramId(), enabled); + } else if (owner instanceof TelegramUser user) { + telegramUserService.updateAgentMode(user.getTelegramId(), enabled); + } else { + throw unsupported(owner, "updateAgentMode"); + } + } + + public void updateThinkingMode(User owner, ThinkingMode mode) { + if (owner instanceof TelegramGroup group) { + telegramGroupService.updateThinkingMode(group.getTelegramId(), mode); + } else if (owner instanceof TelegramUser user) { + telegramUserService.updateThinkingMode(user.getTelegramId(), mode); + } else { + throw unsupported(owner, "updateThinkingMode"); + } + } + + public void updateAssistantRole(User owner, String roleContent) { + if (owner instanceof TelegramGroup group) { + telegramGroupService.updateAssistantRole(group.getTelegramId(), roleContent); + } else if (owner instanceof TelegramUser user) { + telegramUserService.updateAssistantRole(toTelegramApiUser(user), roleContent); + } else { + throw unsupported(owner, "updateAssistantRole"); + } + } + + public AssistantRole getOrCreateAssistantRole(User owner, String defaultContent) { + if (owner instanceof TelegramGroup group) { + return telegramGroupService.getOrCreateAssistantRole(group, defaultContent); + } + if (owner instanceof TelegramUser user) { + return telegramUserService.getOrCreateAssistantRole(user, defaultContent); + } + throw unsupported(owner, "getOrCreateAssistantRole"); + } + + public void updateMenuVersionHash(User owner, String hash) { + if (owner instanceof TelegramGroup group) { + telegramGroupService.updateMenuVersionHash(group.getTelegramId(), hash); + } else if (owner instanceof TelegramUser user) { + telegramUserService.updateMenuVersionHash(user.getTelegramId(), hash); + } else { + throw unsupported(owner, "updateMenuVersionHash"); + } + } + + public String menuVersionHashOf(User owner) { + if (owner instanceof TelegramGroup group) return group.getMenuVersionHash(); + if (owner instanceof TelegramUser user) return user.getMenuVersionHash(); + throw unsupported(owner, "menuVersionHashOf"); + } + + public void setPreferredModel(User owner, String modelName) { + if (owner instanceof TelegramGroup group) { + telegramGroupService.updatePreferredModel(group.getTelegramId(), modelName); + } else if (owner instanceof TelegramUser user) { + user.setPreferredModelId(modelName); + telegramUserService.updateUserActivity(user); + } else { + throw unsupported(owner, "setPreferredModel"); + } + } + + public void clearPreferredModel(User owner) { + setPreferredModel(owner, null); + } + + public Optional getPreferredModel(User owner) { + if (owner == null) return Optional.empty(); + String value = owner.getPreferredModelId(); + return (value != null && !value.isBlank()) ? Optional.of(value) : Optional.empty(); + } + + /** + * Returns the Telegram {@code chat_id} for the given owner (user's id for private chats, + * group chat id for groups). Never returns {@code null} for a valid telegram-domain owner. + */ + public Long telegramIdOf(User owner) { + if (owner instanceof TelegramGroup group) return group.getTelegramId(); + if (owner instanceof TelegramUser user) return user.getTelegramId(); + throw unsupported(owner, "telegramIdOf"); + } + + private static org.telegram.telegrambots.meta.api.objects.User toTelegramApiUser(TelegramUser user) { + org.telegram.telegrambots.meta.api.objects.User api = new org.telegram.telegrambots.meta.api.objects.User(); + api.setId(user.getTelegramId()); + api.setUserName(user.getUsername()); + api.setFirstName(user.getFirstName()); + api.setLastName(user.getLastName()); + return api; + } + + private static IllegalArgumentException unsupported(User owner, String op) { + return new IllegalArgumentException( + "Unsupported owner type for " + op + ": " + (owner == null ? "null" : owner.getClass().getName())); + } +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardService.java index f12d56c3..74b0e2b7 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardService.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardService.java @@ -1,6 +1,7 @@ package io.github.ngirchev.opendaimon.telegram.service; import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.common.repository.UserRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; import org.telegram.telegrambots.meta.api.methods.send.SendMessage; @@ -13,32 +14,29 @@ import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; -import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserRepository; import java.util.List; +import java.util.Optional; @Slf4j public class PersistentKeyboardService { - private final UserModelPreferenceService userModelPreferenceService; private final CoreCommonProperties coreCommonProperties; private final ObjectProvider telegramBotProvider; private final TelegramProperties telegramProperties; private final MessageLocalizationService messageLocalizationService; - private final TelegramUserRepository telegramUserRepository; + private final UserRepository userRepository; - public PersistentKeyboardService(UserModelPreferenceService userModelPreferenceService, - CoreCommonProperties coreCommonProperties, + public PersistentKeyboardService(CoreCommonProperties coreCommonProperties, ObjectProvider telegramBotProvider, TelegramProperties telegramProperties, MessageLocalizationService messageLocalizationService, - TelegramUserRepository telegramUserRepository) { - this.userModelPreferenceService = userModelPreferenceService; + UserRepository userRepository) { this.coreCommonProperties = coreCommonProperties; this.telegramBotProvider = telegramBotProvider; this.telegramProperties = telegramProperties; this.messageLocalizationService = messageLocalizationService; - this.telegramUserRepository = telegramUserRepository; + this.userRepository = userRepository; } /** @@ -107,10 +105,10 @@ public ReplyKeyboardMarkup buildKeyboardMarkup(Long userId, ConversationThread t } private String buildModelLabel(Long userId) { - String lang = telegramUserRepository.findById(userId) - .map(User::getLanguageCode) - .orElse(null); - return userModelPreferenceService.getPreferredModel(userId) + Optional owner = userRepository.findById(userId); + String lang = owner.map(User::getLanguageCode).orElse(null); + return owner.map(User::getPreferredModelId) + .filter(m -> m != null && !m.isBlank()) .map(m -> TelegramCommand.MODEL_KEYBOARD_PREFIX + " " + m) .orElse(TelegramCommand.MODEL_KEYBOARD_PREFIX + " " + messageLocalizationService.getMessage("telegram.model.auto", lang)); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamRenderer.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamRenderer.java index abd03e94..97ccac9f 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamRenderer.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamRenderer.java @@ -48,7 +48,14 @@ public RenderedUpdate render(AgentStreamEvent event, MessageHandlerContext ctx) private RenderedUpdate renderThinking(AgentStreamEvent event, MessageHandlerContext ctx) { TelegramUser user = ctx.getTelegramUser(); - if (user != null && user.getThinkingMode() == ThinkingMode.SILENT) { + // Read thinkingMode from the settings owner (group row in groups, user row in + // privates). Reading it from the invoker directly would break SILENT/SHOW_ALL for + // other group members when their personal thinkingMode differs from the group's. + io.github.ngirchev.opendaimon.common.model.User owner = user != null + ? io.github.ngirchev.opendaimon.telegram.command.TelegramCommand + .resolveOwner(ctx.getCommand(), user) + : null; + if (owner != null && owner.getThinkingMode() == ThinkingMode.SILENT) { return new RenderedUpdate.NoOp(); } String content = event.content(); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java index 6fee1379..ab9aedb0 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java @@ -5,8 +5,10 @@ import org.springframework.beans.factory.ObjectProvider; import org.telegram.telegrambots.meta.api.objects.commands.BotCommand; import org.telegram.telegrambots.meta.exceptions.TelegramApiException; +import io.github.ngirchev.opendaimon.common.model.User; import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramSupportedCommandProvider; +import io.github.ngirchev.opendaimon.telegram.model.TelegramGroup; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import io.github.ngirchev.opendaimon.common.SupportedLanguages; @@ -28,6 +30,7 @@ public class TelegramBotMenuService { private final ObjectProvider telegramBotProvider; private final ObjectProvider commandHandlersProvider; + private final ObjectProvider chatSettingsServiceProvider; /** * Cached hash of the current enabled-commands set. Computed lazily on first access @@ -125,39 +128,61 @@ String computeCurrentMenuVersionHash() { } /** - * Reconciles the chat-scoped command menu for the given user if it differs from the - * current menu version. No-op when the user has no language code (they rely on the - * Default-scope menu refreshed at startup) or when the stored hash already matches. + * Reconciles the chat-scoped command menu for the given settings owner if it differs + * from the current menu version. The owner is polymorphic: a {@link TelegramGroup} for + * group chats and a {@link TelegramUser} for private chats. {@code chatId} is the + * Telegram {@code chat_id} to which the menu is pushed via {@code BotCommandScopeChat}. *

- * Caller is responsible for persisting {@code user} after this method returns {@code true}. - * Telegram API failures are swallowed internally (already handled in {@code setupBotMenuForUser}) - * and surfaced only via logs — this method never propagates a checked exception to callers. + * No-op when the owner has no language code (they rely on the Default-scope menu refreshed + * at startup) or when the stored hash already matches. + *

+ * Side effects: on refresh this method writes {@code currentHash} into the owner's + * {@code menuVersionHash} field in memory AND persists it via the repository (by subtype). + * Telegram API failures are swallowed internally (already handled in + * {@code setupBotMenuForUser}) and surfaced only via logs — this method never propagates + * a checked exception to callers. * - * @param user telegram user whose chat menu may need refreshing - * @return {@code true} if the menu was refreshed and {@code user.menuVersionHash} was updated; - * {@code false} if no work was needed + * @param owner settings owner whose chat menu may need refreshing + * @param chatId Telegram chat id (private-chat userId for users, negative group id for groups) + * @return {@code true} if the menu was refreshed and the owner's hash was updated */ - public boolean reconcileMenuIfStale(TelegramUser user) { - if (user == null) { + public boolean reconcileMenuIfStale(User owner, Long chatId) { + if (owner == null || chatId == null) { return false; } - String languageCode = user.getLanguageCode(); + String languageCode = owner.getLanguageCode(); if (languageCode == null) { - // User is still on Default-scope menu; startup refresh already covers them. + // Owner is still on Default-scope menu; startup refresh already covers them. return false; } String currentHash = getCurrentMenuVersionHash(); - String storedHash = user.getMenuVersionHash(); + String storedHash = menuVersionHashOf(owner); if (storedHash != null && storedHash.equals(currentHash)) { return false; } - setupBotMenuForUser(user.getTelegramId(), languageCode); - user.setMenuVersionHash(currentHash); - log.info("Reconciled menu for chatId={}: versionHash updated from {} to {}", - user.getTelegramId(), storedHash, currentHash); + setupBotMenuForUser(chatId, languageCode); + setMenuVersionHashOn(owner, currentHash); + ChatSettingsService chatSettingsService = chatSettingsServiceProvider != null + ? chatSettingsServiceProvider.getIfAvailable() : null; + if (chatSettingsService != null) { + chatSettingsService.updateMenuVersionHash(owner, currentHash); + } + log.info("Reconciled menu for chatId={} ownerType={}: versionHash updated from {} to {}", + chatId, owner.getClass().getSimpleName(), storedHash, currentHash); return true; } + private static String menuVersionHashOf(User owner) { + if (owner instanceof TelegramGroup group) return group.getMenuVersionHash(); + if (owner instanceof TelegramUser user) return user.getMenuVersionHash(); + return null; + } + + private static void setMenuVersionHashOn(User owner, String hash) { + if (owner instanceof TelegramGroup group) group.setMenuVersionHash(hash); + else if (owner instanceof TelegramUser user) user.setMenuVersionHash(hash); + } + /** * Builds list of commands from handlers for the given language. */ diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatOwnerLookup.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatOwnerLookup.java new file mode 100644 index 00000000..d1789ccf --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatOwnerLookup.java @@ -0,0 +1,24 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import lombok.RequiredArgsConstructor; +import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup; + +import java.util.Optional; + +/** + * Telegram-side binding of {@link ChatOwnerLookup} — delegates to + * {@link ChatSettingsOwnerResolver#findByChatId(Long)}. Registered as the + * primary {@code ChatOwnerLookup} bean when the Telegram module is active, + * overriding the common-module {@link ChatOwnerLookup#NOOP} fallback. + */ +@RequiredArgsConstructor +public class TelegramChatOwnerLookup implements ChatOwnerLookup { + + private final ChatSettingsOwnerResolver resolver; + + @Override + public Optional findByChatId(Long chatId) { + return resolver.findByChatId(chatId); + } +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java new file mode 100644 index 00000000..93b55fe9 --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java @@ -0,0 +1,165 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; +import org.telegram.telegrambots.meta.api.objects.Chat; +import io.github.ngirchev.opendaimon.common.model.AssistantRole; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; +import io.github.ngirchev.opendaimon.common.service.AssistantRoleService; +import io.github.ngirchev.opendaimon.telegram.model.TelegramGroup; +import io.github.ngirchev.opendaimon.telegram.repository.TelegramGroupRepository; + +import java.time.OffsetDateTime; +import java.util.Optional; + +/** + * Manages {@link TelegramGroup} rows — the settings-owner entity for Telegram + * group and supergroup chats. Mirrors {@link TelegramUserService} methods that + * mutate per-chat state, but keyed on the group {@code chat_id}. + *

+ * Deliberately does NOT implement {@code IUserService}: the bulkhead priority + * source stays a single source (the invoker's {@code TelegramUser}). + */ +@Slf4j +@RequiredArgsConstructor +public class TelegramGroupService { + + private static final String GROUP_NOT_FOUND = "Telegram group not found"; + + private final TelegramGroupRepository telegramGroupRepository; + private final AssistantRoleService assistantRoleService; + /** Default value for {@code agentModeEnabled} on new groups. Sourced from {@code open-daimon.agent.enabled}. */ + private final boolean defaultAgentModeEnabled; + + public Optional findByChatId(Long chatId) { + return telegramGroupRepository.findByTelegramId(chatId); + } + + @Transactional + public TelegramGroup getOrCreateGroup(Chat chat) { + if (chat == null || chat.getId() == null) { + throw new IllegalArgumentException("Chat and chat.id are required"); + } + return telegramGroupRepository.findByTelegramId(chat.getId()) + .map(existing -> updateGroupInfo(existing, chat)) + .orElseGet(() -> createGroupInner(chat)); + } + + @Transactional + public TelegramGroup updateLanguageCode(Long chatId, String languageCode) { + TelegramGroup group = requireGroup(chatId); + String normalized = languageCode != null && !languageCode.isBlank() + ? languageCode.trim().toLowerCase().split("-")[0] + : null; + group.setLanguageCode(normalized); + stampTimestamps(group); + return telegramGroupRepository.save(group); + } + + @Transactional + public void updateThinkingMode(Long chatId, ThinkingMode thinkingMode) { + TelegramGroup group = requireGroup(chatId); + group.setThinkingMode(thinkingMode); + stampTimestamps(group); + telegramGroupRepository.save(group); + } + + @Transactional + public void updateAgentMode(Long chatId, boolean enabled) { + TelegramGroup group = requireGroup(chatId); + group.setAgentModeEnabled(enabled); + stampTimestamps(group); + telegramGroupRepository.save(group); + } + + @Transactional + public TelegramGroup updateAssistantRole(Long chatId, String assistantRoleContent) { + TelegramGroup group = requireGroup(chatId); + AssistantRole role = assistantRoleService.updateActiveRole(group, assistantRoleContent); + group.setCurrentAssistantRole(role); + stampTimestamps(group); + return telegramGroupRepository.save(group); + } + + @Transactional + public AssistantRole getOrCreateAssistantRole(TelegramGroup group, String defaultContent) { + Long chatId = group.getTelegramId(); + if (chatId == null) { + throw new IllegalArgumentException("Group telegramId is null"); + } + TelegramGroup managed = requireGroup(chatId); + AssistantRole role = managed.getCurrentAssistantRole(); + if (role == null) { + role = assistantRoleService.getOrCreateDefaultRole(managed, defaultContent); + managed.setCurrentAssistantRole(role); + stampTimestamps(managed); + telegramGroupRepository.save(managed); + } + // Initialize role fields in this transaction to avoid LazyInitializationException later + role.getId(); + role.getVersion(); + role.getContent(); + return role; + } + + @Transactional + public void updateMenuVersionHash(Long chatId, String hash) { + TelegramGroup group = requireGroup(chatId); + group.setMenuVersionHash(hash); + group.setUpdatedAt(OffsetDateTime.now()); + telegramGroupRepository.save(group); + } + + @Transactional + public TelegramGroup updatePreferredModel(Long chatId, String modelName) { + TelegramGroup group = requireGroup(chatId); + group.setPreferredModelId(modelName); + stampTimestamps(group); + return telegramGroupRepository.save(group); + } + + private TelegramGroup requireGroup(Long chatId) { + return telegramGroupRepository.findByTelegramId(chatId) + .orElseThrow(() -> new RuntimeException(GROUP_NOT_FOUND + ": chatId=" + chatId)); + } + + private TelegramGroup createGroupInner(Chat chat) { + TelegramGroup group = new TelegramGroup(); + group.setTelegramId(chat.getId()); + group.setTitle(chat.getTitle()); + group.setType(chat.getType()); + OffsetDateTime now = OffsetDateTime.now(); + group.setCreatedAt(now); + group.setUpdatedAt(now); + group.setLastActivityAt(now); + group.setIsBlocked(false); + group.setIsAdmin(false); + group.setIsPremium(false); + group.setLanguageCode(null); + group.setAgentModeEnabled(defaultAgentModeEnabled); + TelegramGroup saved = telegramGroupRepository.save(group); + log.info("Telegram group created: id={}, chatId={}, title='{}', type={}", + saved.getId(), saved.getTelegramId(), saved.getTitle(), saved.getType()); + return saved; + } + + private TelegramGroup updateGroupInfo(TelegramGroup group, Chat chat) { + String title = chat.getTitle(); + if (title != null) { + group.setTitle(title); + } + String type = chat.getType(); + if (type != null) { + group.setType(type); + } + stampTimestamps(group); + return telegramGroupRepository.save(group); + } + + private void stampTimestamps(TelegramGroup group) { + OffsetDateTime now = OffsetDateTime.now(); + group.setUpdatedAt(now); + group.setLastActivityAt(now); + } +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramMessageService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramMessageService.java index 9d838222..da65b27e 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramMessageService.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramMessageService.java @@ -11,6 +11,8 @@ import io.github.ngirchev.opendaimon.common.model.OpenDaimonMessage; import io.github.ngirchev.opendaimon.common.model.RequestType; import io.github.ngirchev.opendaimon.common.model.ThreadScopeKind; +import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup; import io.github.ngirchev.opendaimon.common.service.ConversationThreadService; import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; import io.github.ngirchev.opendaimon.common.service.OpenDaimonMessageService; @@ -41,6 +43,9 @@ public class TelegramMessageService { private final ConversationThreadService conversationThreadService; /** Self-reference for transactional proxy (avoids bypassing @Transactional on internal calls). */ private final ObjectProvider selfProvider; + /** Resolves per-chat settings owner (TelegramGroup for group chats, TelegramUser for privates). */ + private final ChatOwnerLookup chatOwnerLookup; + private final ChatSettingsService chatSettingsService; /** * Saves USER message from Telegram user with session and conversation thread. @@ -108,7 +113,8 @@ public OpenDaimonMessage saveUserMessage( String roleContent = assistantRoleContent != null ? assistantRoleContent : messageLocalizationService.getMessage(coreCommonProperties.getAssistantRole(), telegramUser.getLanguageCode()); - AssistantRole assistantRole = telegramUserService.getOrCreateAssistantRole(telegramUser, roleContent); + User assistantRoleOwner = resolveSettingsOwner(telegramUser, chatId); + AssistantRole assistantRole = chatSettingsService.getOrCreateAssistantRole(assistantRoleOwner, roleContent); // Prepare Telegram-specific metadata Map metadata = null; @@ -202,7 +208,10 @@ public OpenDaimonMessage saveAssistantMessage( String roleContent = assistantRoleContent != null ? assistantRoleContent : messageLocalizationService.getMessage(coreCommonProperties.getAssistantRole(), telegramUser.getLanguageCode()); - AssistantRole assistantRole = telegramUserService.getOrCreateAssistantRole(telegramUser, roleContent); + Long chatScopeId = thread != null && thread.getScopeKind() == ThreadScopeKind.TELEGRAM_CHAT + ? thread.getScopeId() : null; + User assistantRoleOwner = resolveSettingsOwner(telegramUser, chatScopeId); + AssistantRole assistantRole = chatSettingsService.getOrCreateAssistantRole(assistantRoleOwner, roleContent); return messageService.saveAssistantMessage( telegramUser, content, @@ -265,15 +274,31 @@ public OpenDaimonMessage saveAssistantErrorMessage( String roleContent = assistantRoleContent != null ? assistantRoleContent : messageLocalizationService.getMessage(coreCommonProperties.getAssistantRole(), telegramUser.getLanguageCode()); - AssistantRole assistantRole = telegramUserService.getOrCreateAssistantRole(telegramUser, roleContent); - + Long chatScopeId = thread != null && thread.getScopeKind() == ThreadScopeKind.TELEGRAM_CHAT + ? thread.getScopeId() : null; + User assistantRoleOwner = resolveSettingsOwner(telegramUser, chatScopeId); + AssistantRole assistantRole = chatSettingsService.getOrCreateAssistantRole(assistantRoleOwner, roleContent); + // Use base MessageService to save message return messageService.saveAssistantErrorMessage( telegramUser, - errorMessage, - serviceName, - assistantRole, + errorMessage, + serviceName, + assistantRole, errorData, thread); } + + /** + * Resolves the settings-owner for a save operation: for group chats we want the + * {@link io.github.ngirchev.opendaimon.telegram.model.TelegramGroup} row so the assistant role + * comes from the shared group settings; in private chats we fall back to the invoker's + * {@code TelegramUser}. When {@code chatId} is unknown (legacy user-scope thread) the invoker is used. + */ + private User resolveSettingsOwner(TelegramUser invoker, Long chatId) { + if (chatId == null) { + return invoker; + } + return chatOwnerLookup.findByChatId(chatId).orElse(invoker); + } } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/TelegramBotTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/TelegramBotTest.java index 4c03eb42..e8486493 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/TelegramBotTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/TelegramBotTest.java @@ -1028,16 +1028,11 @@ config, new DefaultBotOptions(), commandSyncService, userService, telegramUser.setTelegramId(200L); telegramUser.setLanguageCode("en"); when(userService.getOrCreateUser(any(User.class))).thenReturn(telegramUser); - when(menuService.reconcileMenuIfStale(any(TelegramUser.class))).thenAnswer(inv -> { - TelegramUser u = inv.getArgument(0); - u.setMenuVersionHash("fresh-hash"); - return true; - }); + when(menuService.reconcileMenuIfStale(any(io.github.ngirchev.opendaimon.common.model.User.class), anyLong())).thenReturn(true); reconcilingBot.mapToTelegramTextCommand(update); - verify(menuService).reconcileMenuIfStale(telegramUser); - verify(userService).updateMenuVersionHash(200L, "fresh-hash"); + verify(menuService).reconcileMenuIfStale(eq(telegramUser), eq(100L)); } @Test @@ -1068,12 +1063,11 @@ config, new DefaultBotOptions(), commandSyncService, userService, telegramUser.setTelegramId(200L); telegramUser.setLanguageCode("en"); when(userService.getOrCreateUser(any(User.class))).thenReturn(telegramUser); - when(menuService.reconcileMenuIfStale(any(TelegramUser.class))).thenReturn(false); + when(menuService.reconcileMenuIfStale(any(io.github.ngirchev.opendaimon.common.model.User.class), anyLong())).thenReturn(false); reconcilingBot.mapToTelegramTextCommand(update); - verify(menuService).reconcileMenuIfStale(telegramUser); - verify(userService, never()).updateMenuVersionHash(anyLong(), anyString()); + verify(menuService).reconcileMenuIfStale(eq(telegramUser), eq(100L)); } @Test @@ -1111,16 +1105,11 @@ config, new DefaultBotOptions(), commandSyncService, userService, TelegramUserSession session = new TelegramUserSession(); session.setBotStatus(null); when(userService.getOrCreateSession(any(User.class))).thenReturn(session); - when(menuService.reconcileMenuIfStale(any(TelegramUser.class))).thenAnswer(inv -> { - TelegramUser u = inv.getArgument(0); - u.setMenuVersionHash("fresh-hash"); - return true; - }); + when(menuService.reconcileMenuIfStale(any(io.github.ngirchev.opendaimon.common.model.User.class), anyLong())).thenReturn(true); reconcilingBot.mapToTelegramCommand(update); - verify(menuService).reconcileMenuIfStale(telegramUser); - verify(userService).updateMenuVersionHash(200L, "fresh-hash"); + verify(menuService).reconcileMenuIfStale(eq(telegramUser), eq(100L)); } @Test @@ -1151,13 +1140,12 @@ config, new DefaultBotOptions(), commandSyncService, userService, telegramUser.setTelegramId(200L); telegramUser.setLanguageCode("en"); when(userService.getOrCreateUser(any(User.class))).thenReturn(telegramUser); - when(menuService.reconcileMenuIfStale(any(TelegramUser.class))) + when(menuService.reconcileMenuIfStale(any(io.github.ngirchev.opendaimon.common.model.User.class), anyLong())) .thenThrow(new RuntimeException("reconcile blew up")); TelegramCommand cmd = reconcilingBot.mapToTelegramTextCommand(update); assertNotNull(cmd); assertEquals("/start", cmd.commandType().command()); - verify(userService, never()).updateMenuVersionHash(anyLong(), anyString()); } } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/StartTelegramTextCommandHandlerProviderTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/StartTelegramTextCommandHandlerProviderTest.java index 0ed59b02..1cd17e60 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/StartTelegramTextCommandHandlerProviderTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/StartTelegramTextCommandHandlerProviderTest.java @@ -38,6 +38,8 @@ import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; import io.github.ngirchev.opendaimon.telegram.service.UserRecentModelService; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; +import io.github.ngirchev.opendaimon.common.repository.UserRepository; import java.util.List; import java.util.concurrent.ScheduledExecutorService; @@ -307,6 +309,16 @@ public TelegramBotMenuService telegramBotMenuService() { public UserRecentModelService userRecentModelService() { return mock(UserRecentModelService.class); } + + @Bean + public ChatSettingsService chatSettingsService() { + return mock(ChatSettingsService.class); + } + + @Bean + public UserRepository userRepository() { + return mock(UserRepository.class); + } } } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandlerTest.java index 732cd37c..d267f7fb 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandlerTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/LanguageTelegramCommandHandlerTest.java @@ -22,6 +22,7 @@ import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType; import io.github.ngirchev.opendaimon.telegram.command.handler.TelegramCommandHandlerException; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import io.github.ngirchev.opendaimon.telegram.service.TelegramBotMenuService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; @@ -51,6 +52,8 @@ class LanguageTelegramCommandHandlerTest { private TelegramUserService telegramUserService; @Mock private TelegramBotMenuService telegramBotMenuService; + @Mock + private ChatSettingsService chatSettingsService; private LanguageTelegramCommandHandler handler; @@ -74,7 +77,8 @@ void setUp() { when(messageLocalizationService.getMessage(eq("telegram.language.unknown"), anyString())) .thenReturn("Unknown language"); handler = new LanguageTelegramCommandHandler( - telegramBotProvider, typingIndicatorService, messageLocalizationService, telegramUserService, telegramBotMenuService); + telegramBotProvider, typingIndicatorService, messageLocalizationService, telegramUserService, telegramBotMenuService, + chatSettingsService); } @Test @@ -208,7 +212,7 @@ void handleInner_whenCallbackRu_thenUpdatesLanguageAndClosesMenu() throws Telegr handler.handleInner(command); - verify(telegramUserService).updateLanguageCode(eq(from.getId()), eq("ru")); + verify(chatSettingsService).updateLanguageCode(any(), eq("ru")); verify(telegramBotMenuService).setupBotMenuForUser(eq(CHAT_ID), eq("ru")); verify(telegramBot).execute(any(org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery.class)); verify(telegramBot).execute(any(DeleteMessage.class)); @@ -237,7 +241,7 @@ void handle_whenCallbackRu_doesNotStartTyping() throws TelegramApiException { verify(typingIndicatorService, never()).startTyping(CHAT_ID); verify(typingIndicatorService, never()).stopTyping(CHAT_ID); - verify(telegramUserService).updateLanguageCode(USER_ID, "ru"); + verify(chatSettingsService).updateLanguageCode(any(), eq("ru")); verify(telegramBot).execute(any(DeleteMessage.class)); } @@ -261,7 +265,7 @@ void handleInner_whenCallbackEn_thenUpdatesLanguage() throws TelegramApiExceptio handler.handleInner(command); - verify(telegramUserService).updateLanguageCode(eq(from.getId()), eq("en")); + verify(chatSettingsService).updateLanguageCode(any(), eq("en")); verify(telegramBot).execute(any(DeleteMessage.class)); verify(telegramBot, never()).sendMessage(anyLong(), anyString(), any(), any()); } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java index d7633085..e8c2418d 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java @@ -51,7 +51,7 @@ import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; -import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import java.util.List; import java.util.Map; @@ -90,7 +90,7 @@ class MessageTelegramCommandHandlerTest { @Mock private AIRequestPipeline aiRequestPipeline; @Mock - private UserModelPreferenceService userModelPreferenceService; + private ChatSettingsService chatSettingsService; @Mock private PersistentKeyboardService persistentKeyboardService; @Mock @@ -124,7 +124,7 @@ void setUp() { TelegramMessageHandlerActions actions = new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, - aiRequestPipeline, telegramProperties, userModelPreferenceService, + aiRequestPipeline, telegramProperties, chatSettingsService, persistentKeyboardService, replyImageAttachmentService, messageSender, null, null, 10, false); diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandlerTest.java index dc15d3e0..7eaae29c 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandlerTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModeTelegramCommandHandlerTest.java @@ -48,6 +48,8 @@ class ModeTelegramCommandHandlerTest { private MessageLocalizationService messageLocalizationService; @Mock private TelegramUserService telegramUserService; + @Mock + private io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService chatSettingsService; private ModeTelegramCommandHandler handler; @@ -71,7 +73,8 @@ void setUp() { when(messageLocalizationService.getMessage(eq("telegram.mode.unknown"), anyString())) .thenReturn("Unknown mode"); handler = new ModeTelegramCommandHandler( - telegramBotProvider, typingIndicatorService, messageLocalizationService, telegramUserService); + telegramBotProvider, typingIndicatorService, messageLocalizationService, telegramUserService, + chatSettingsService); } @Test @@ -203,7 +206,7 @@ void handleInner_whenCallbackAgent_thenUpdatesAgentModeAndClosesMenu() throws Te handler.handleInner(command); - verify(telegramUserService).updateAgentMode(USER_ID, true); + verify(chatSettingsService).updateAgentMode(any(), eq(true)); verify(telegramBot).execute(any(org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery.class)); verify(telegramBot).execute(any(DeleteMessage.class)); } @@ -228,7 +231,7 @@ void handleInner_whenCallbackRegular_thenUpdatesRegularModeAndClosesMenu() throw handler.handleInner(command); - verify(telegramUserService).updateAgentMode(USER_ID, false); + verify(chatSettingsService).updateAgentMode(any(), eq(false)); verify(telegramBot).execute(any(org.telegram.telegrambots.meta.api.methods.AnswerCallbackQuery.class)); verify(telegramBot).execute(any(DeleteMessage.class)); } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandlerTest.java index a60f4627..34784fc9 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandlerTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ModelTelegramCommandHandlerTest.java @@ -17,7 +17,7 @@ import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; -import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import io.github.ngirchev.opendaimon.telegram.service.UserRecentModelService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -62,7 +62,7 @@ class ModelTelegramCommandHandlerTest { @Mock private TypingIndicatorService typingIndicatorService; @Mock private MessageLocalizationService messageLocalizationService; @Mock private TelegramUserService telegramUserService; - @Mock private UserModelPreferenceService userModelPreferenceService; + @Mock private ChatSettingsService chatSettingsService; @Mock private AIGatewayRegistry aiGatewayRegistry; @Mock private IUserPriorityService userPriorityService; @Mock private PersistentKeyboardService persistentKeyboardService; @@ -89,7 +89,7 @@ void setUp() { typingIndicatorService, messageLocalizationService, telegramUserService, - userModelPreferenceService, + chatSettingsService, aiGatewayRegistry, userPriorityService, persistentKeyboardService, @@ -178,7 +178,7 @@ void shouldRecordUsageOnExplicitPick() { handler.handleInner(command); - verify(userModelPreferenceService).setPreferredModel(USER_ID, "model-2"); + verify(chatSettingsService).setPreferredModel(any(), eq("model-2")); verify(userRecentModelService).recordUsage(USER_ID, "model-2"); } @@ -207,7 +207,7 @@ void shouldNotRecordUsageOnAutoPick() { handler.handleInner(command); - verify(userModelPreferenceService).clearPreference(USER_ID); + verify(chatSettingsService).clearPreferredModel(any()); verify(userRecentModelService, never()).recordUsage(any(), anyString()); } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/RoleTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/RoleTelegramCommandHandlerTest.java index e742afe0..48549502 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/RoleTelegramCommandHandlerTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/RoleTelegramCommandHandlerTest.java @@ -44,6 +44,8 @@ class RoleTelegramCommandHandlerTest { private TypingIndicatorService typingIndicatorService; @Mock private TelegramUserService telegramUserService; + @Mock + private io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService chatSettingsService; private MessageLocalizationService messageLocalizationService; private CoreCommonProperties coreCommonProperties; @@ -64,7 +66,7 @@ void setUp() { when(botProvider.getObject()).thenReturn(telegramBot); handler = new RoleTelegramCommandHandler(botProvider, typingIndicatorService, messageLocalizationService, - telegramUserService, coreCommonProperties); + telegramUserService, coreCommonProperties, chatSettingsService); } @Test @@ -144,7 +146,7 @@ void handleInner_whenEmptyUserText_thenShowsCurrentRoleAndMenu() throws Exceptio role.setContent("Default role content"); telegramUser.setCurrentAssistantRole(role); - when(telegramUserService.getOrCreateAssistantRole(any(TelegramUser.class), eq("You are a helpful assistant."))) + when(chatSettingsService.getOrCreateAssistantRole(any(), eq("You are a helpful assistant."))) .thenReturn(role); when(telegramUserService.getOrCreateUser(from)).thenReturn(telegramUser); @@ -154,7 +156,7 @@ void handleInner_whenEmptyUserText_thenShowsCurrentRoleAndMenu() throws Exceptio assertNull(handler.handleInner(command)); verify(telegramUserService).getOrCreateUser(from); - verify(telegramUserService).getOrCreateAssistantRole(any(TelegramUser.class), eq("You are a helpful assistant.")); + verify(chatSettingsService).getOrCreateAssistantRole(any(), eq("You are a helpful assistant.")); verify(telegramBot, atLeast(1)).sendMessage(eq(CHAT_ID), anyString(), any(), any()); } @@ -178,7 +180,7 @@ void handleInner_whenUserTextProvided_thenUpdatesRoleAndSendsConfirmation() thro assertNull(handler.handleInner(command)); verify(telegramUserService).getOrCreateUser(from); - verify(telegramUserService).updateAssistantRole(from, "New role text"); + verify(chatSettingsService).updateAssistantRole(any(), eq("New role text")); verify(telegramBot).clearStatus(200L); verify(telegramBot).sendMessage(eq(CHAT_ID), contains("Assistant role updated successfully"), any(), any()); } @@ -231,7 +233,7 @@ void handleInner_whenCallbackPreset_thenUpdatesRoleAndSendsConfirmation() throws assertNull(handler.handleInner(command)); - verify(telegramUserService).updateAssistantRole(from, "You are a helpful assistant."); + verify(chatSettingsService).updateAssistantRole(any(), eq("You are a helpful assistant.")); verify(telegramBot).clearStatus(200L); verify(telegramBot, atLeast(1)).execute(any(org.telegram.telegrambots.meta.api.methods.BotApiMethod.class)); verify(telegramBot).execute(any(DeleteMessage.class)); diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandlerTest.java index 09006997..892c19b0 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandlerTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ThinkingTelegramCommandHandlerTest.java @@ -47,6 +47,7 @@ class ThinkingTelegramCommandHandlerTest { @Mock private MessageLocalizationService messageLocalizationService; @Mock private TelegramUserService telegramUserService; @Mock private TelegramBotMenuService telegramBotMenuService; + @Mock private io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService chatSettingsService; private ThinkingTelegramCommandHandler handler; @@ -72,7 +73,8 @@ void setUp() { when(messageLocalizationService.getMessage(eq("telegram.thinking.unknown"), anyString())) .thenReturn("Unknown option"); handler = new ThinkingTelegramCommandHandler( - telegramBotProvider, typingIndicatorService, messageLocalizationService, telegramUserService, telegramBotMenuService); + telegramBotProvider, typingIndicatorService, messageLocalizationService, telegramUserService, telegramBotMenuService, + chatSettingsService); } @Test @@ -272,7 +274,7 @@ void shouldPersistShowAllWhenThinkingShowAllCallback() throws TelegramApiExcepti handler.handleInner(command); - verify(telegramUserService).updateThinkingMode(eq(USER_ID), eq(ThinkingMode.SHOW_ALL)); + verify(chatSettingsService).updateThinkingMode(any(), eq(ThinkingMode.SHOW_ALL)); verify(telegramBot).execute(any(AnswerCallbackQuery.class)); verify(telegramBot).execute(any(DeleteMessage.class)); } @@ -297,7 +299,7 @@ void shouldPersistHideReasoningWhenThinkingHideReasoningCallback() throws Telegr handler.handleInner(command); - verify(telegramUserService).updateThinkingMode(eq(USER_ID), eq(ThinkingMode.HIDE_REASONING)); + verify(chatSettingsService).updateThinkingMode(any(), eq(ThinkingMode.HIDE_REASONING)); verify(telegramBot).execute(any(AnswerCallbackQuery.class)); verify(telegramBot).execute(any(DeleteMessage.class)); } @@ -322,7 +324,7 @@ void shouldPersistSilentWhenThinkingSilentCallback() throws TelegramApiException handler.handleInner(command); - verify(telegramUserService).updateThinkingMode(eq(USER_ID), eq(ThinkingMode.SILENT)); + verify(chatSettingsService).updateThinkingMode(any(), eq(ThinkingMode.SILENT)); verify(telegramBot).execute(any(AnswerCallbackQuery.class)); verify(telegramBot).execute(any(DeleteMessage.class)); } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java index c9f6fcd2..c10b7401 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java @@ -20,7 +20,7 @@ import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; -import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -65,7 +65,7 @@ class TelegramMessageHandlerActionsAgentTest { @Mock private AIGatewayRegistry aiGatewayRegistry; @Mock private OpenDaimonMessageService messageService; @Mock private AIRequestPipeline aiRequestPipeline; - @Mock private UserModelPreferenceService userModelPreferenceService; + @Mock private ChatSettingsService chatSettingsService; @Mock private PersistentKeyboardService persistentKeyboardService; @Mock private ReplyImageAttachmentService replyImageAttachmentService; @Mock private TelegramMessageSender messageSender; @@ -88,7 +88,7 @@ void setUp() { actions = new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, - aiRequestPipeline, telegramProperties, userModelPreferenceService, + aiRequestPipeline, telegramProperties, chatSettingsService, persistentKeyboardService, replyImageAttachmentService, messageSender, agentExecutor, agentStreamRenderer, MAX_ITERATIONS, true); } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java index b3bfa9c6..dea9aad0 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java @@ -19,7 +19,7 @@ import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; -import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -71,7 +71,7 @@ class TelegramMessageHandlerActionsStreamingTest { @Mock private AIGatewayRegistry aiGatewayRegistry; @Mock private OpenDaimonMessageService messageService; @Mock private AIRequestPipeline aiRequestPipeline; - @Mock private UserModelPreferenceService userModelPreferenceService; + @Mock private ChatSettingsService chatSettingsService; @Mock private PersistentKeyboardService persistentKeyboardService; @Mock private ReplyImageAttachmentService replyImageAttachmentService; @Mock private TelegramMessageSender messageSender; @@ -92,7 +92,7 @@ void setUp() { actions = new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, - aiRequestPipeline, telegramProperties, userModelPreferenceService, + aiRequestPipeline, telegramProperties, chatSettingsService, persistentKeyboardService, replyImageAttachmentService, messageSender, agentExecutor, agentStreamRenderer, MAX_ITERATIONS, true); } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java index 007d3a54..e1b032ff 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java @@ -13,7 +13,7 @@ import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; -import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -66,7 +66,7 @@ class TelegramMessageHandlerActionsTentativeEditTest { @Mock private AIGatewayRegistry aiGatewayRegistry; @Mock private OpenDaimonMessageService messageService; @Mock private AIRequestPipeline aiRequestPipeline; - @Mock private UserModelPreferenceService userModelPreferenceService; + @Mock private ChatSettingsService chatSettingsService; @Mock private PersistentKeyboardService persistentKeyboardService; @Mock private ReplyImageAttachmentService replyImageAttachmentService; @Mock private TelegramMessageSender messageSender; @@ -88,7 +88,7 @@ void setUp() { actions = new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, - aiRequestPipeline, telegramProperties, userModelPreferenceService, + aiRequestPipeline, telegramProperties, chatSettingsService, persistentKeyboardService, replyImageAttachmentService, messageSender, agentExecutor, agentStreamRenderer, MAX_ITERATIONS, true); } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsOwnerResolverTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsOwnerResolverTest.java new file mode 100644 index 00000000..67aacdb4 --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsOwnerResolverTest.java @@ -0,0 +1,121 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import io.github.ngirchev.opendaimon.common.model.User; +import io.github.ngirchev.opendaimon.telegram.model.TelegramGroup; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.telegram.telegrambots.meta.api.objects.Chat; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ChatSettingsOwnerResolverTest { + + private static final Long PRIVATE_CHAT_ID = 42L; + private static final Long GROUP_CHAT_ID = -1001234567890L; + + @Mock + private TelegramUserService telegramUserService; + @Mock + private TelegramGroupService telegramGroupService; + + private ChatSettingsOwnerResolver resolver; + + @BeforeEach + void setUp() { + resolver = new ChatSettingsOwnerResolver(telegramUserService, telegramGroupService); + } + + @Test + void shouldReturnTelegramUserWhenChatIsPrivate() { + Chat chat = new Chat(); + chat.setType("private"); + org.telegram.telegrambots.meta.api.objects.User invoker = + new org.telegram.telegrambots.meta.api.objects.User(PRIVATE_CHAT_ID, "alice", false); + TelegramUser expected = new TelegramUser(); + when(telegramUserService.getOrCreateUser(invoker)).thenReturn(expected); + + User owner = resolver.resolveForChat(chat, invoker); + + assertThat(owner).isSameAs(expected); + verify(telegramGroupService, never()).getOrCreateGroup(any()); + } + + @Test + void shouldReturnTelegramGroupWhenChatIsGroup() { + Chat chat = new Chat(); + chat.setId(GROUP_CHAT_ID); + chat.setType("group"); + TelegramGroup expected = new TelegramGroup(); + when(telegramGroupService.getOrCreateGroup(chat)).thenReturn(expected); + + User owner = resolver.resolveForChat(chat, + new org.telegram.telegrambots.meta.api.objects.User(1L, "bob", false)); + + assertThat(owner).isSameAs(expected); + verify(telegramUserService, never()).getOrCreateUser(any()); + } + + @Test + void shouldReturnTelegramGroupWhenChatIsSupergroup() { + Chat chat = new Chat(); + chat.setId(GROUP_CHAT_ID); + chat.setType("supergroup"); + TelegramGroup expected = new TelegramGroup(); + when(telegramGroupService.getOrCreateGroup(chat)).thenReturn(expected); + + User owner = resolver.resolveForChat(chat, + new org.telegram.telegrambots.meta.api.objects.User(1L, "bob", false)); + + assertThat(owner).isSameAs(expected); + } + + @Test + void shouldFallBackToUserWhenChatIsNull() { + org.telegram.telegrambots.meta.api.objects.User invoker = + new org.telegram.telegrambots.meta.api.objects.User(PRIVATE_CHAT_ID, "alice", false); + TelegramUser expected = new TelegramUser(); + when(telegramUserService.getOrCreateUser(invoker)).thenReturn(expected); + + User owner = resolver.resolveForChat(null, invoker); + + assertThat(owner).isSameAs(expected); + } + + @Test + void shouldReturnGroupFromFindByChatIdWhenIdIsNegative() { + TelegramGroup expected = new TelegramGroup(); + when(telegramGroupService.findByChatId(GROUP_CHAT_ID)).thenReturn(Optional.of(expected)); + + Optional result = resolver.findByChatId(GROUP_CHAT_ID); + + assertThat(result).containsSame(expected); + verify(telegramUserService, never()).findByTelegramId(any()); + } + + @Test + void shouldReturnUserFromFindByChatIdWhenIdIsPositive() { + TelegramUser expected = new TelegramUser(); + when(telegramUserService.findByTelegramId(PRIVATE_CHAT_ID)).thenReturn(Optional.of(expected)); + + Optional result = resolver.findByChatId(PRIVATE_CHAT_ID); + + assertThat(result).containsSame(expected); + verify(telegramGroupService, never()).findByChatId(any()); + } + + @Test + void shouldReturnEmptyWhenFindByChatIdReceivesNull() { + assertThat(resolver.findByChatId(null)).isEmpty(); + } +} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsServiceTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsServiceTest.java new file mode 100644 index 00000000..36b9eced --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/ChatSettingsServiceTest.java @@ -0,0 +1,198 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import io.github.ngirchev.opendaimon.common.model.AssistantRole; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; +import io.github.ngirchev.opendaimon.telegram.model.TelegramGroup; +import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Verifies polymorphic dispatch of {@link ChatSettingsService}: each mutation method + * must route to {@code TelegramGroupService} for group owners and to + * {@code TelegramUserService} for user owners, keyed on the subtype's + * {@code telegramId} (which is {@code chat_id} in both cases). + */ +@ExtendWith(MockitoExtension.class) +class ChatSettingsServiceTest { + + private static final Long USER_TELEGRAM_ID = 100L; + private static final Long GROUP_CHAT_ID = -1001234567890L; + + @Mock + private TelegramUserService telegramUserService; + @Mock + private TelegramGroupService telegramGroupService; + + private ChatSettingsService service; + + private TelegramUser userOwner; + private TelegramGroup groupOwner; + + @BeforeEach + void setUp() { + service = new ChatSettingsService(telegramUserService, telegramGroupService); + userOwner = new TelegramUser(); + userOwner.setTelegramId(USER_TELEGRAM_ID); + groupOwner = new TelegramGroup(); + groupOwner.setTelegramId(GROUP_CHAT_ID); + } + + @Test + void shouldDispatchLanguageUpdateToGroupServiceWhenOwnerIsGroup() { + service.updateLanguageCode(groupOwner, "ru"); + + verify(telegramGroupService).updateLanguageCode(GROUP_CHAT_ID, "ru"); + verify(telegramUserService, never()).updateLanguageCode(any(), any()); + } + + @Test + void shouldDispatchLanguageUpdateToUserServiceWhenOwnerIsUser() { + service.updateLanguageCode(userOwner, "en"); + + verify(telegramUserService).updateLanguageCode(USER_TELEGRAM_ID, "en"); + verify(telegramGroupService, never()).updateLanguageCode(any(), any()); + } + + @Test + void shouldDispatchAgentModeToGroupServiceWhenOwnerIsGroup() { + service.updateAgentMode(groupOwner, true); + + verify(telegramGroupService).updateAgentMode(GROUP_CHAT_ID, true); + verify(telegramUserService, never()).updateAgentMode(any(), eq(true)); + } + + @Test + void shouldDispatchAgentModeToUserServiceWhenOwnerIsUser() { + service.updateAgentMode(userOwner, false); + + verify(telegramUserService).updateAgentMode(USER_TELEGRAM_ID, false); + verify(telegramGroupService, never()).updateAgentMode(any(), eq(false)); + } + + @Test + void shouldDispatchThinkingModeByOwnerType() { + service.updateThinkingMode(groupOwner, ThinkingMode.SILENT); + service.updateThinkingMode(userOwner, ThinkingMode.SHOW_ALL); + + verify(telegramGroupService).updateThinkingMode(GROUP_CHAT_ID, ThinkingMode.SILENT); + verify(telegramUserService).updateThinkingMode(USER_TELEGRAM_ID, ThinkingMode.SHOW_ALL); + } + + @Test + void shouldDispatchAssistantRoleUpdateByOwnerType() { + service.updateAssistantRole(groupOwner, "group role"); + + verify(telegramGroupService).updateAssistantRole(GROUP_CHAT_ID, "group role"); + verify(telegramUserService, never()).updateAssistantRole(any(), any()); + } + + @Test + void shouldDispatchGetOrCreateAssistantRoleToGroupServiceForGroup() { + AssistantRole role = new AssistantRole(); + when(telegramGroupService.getOrCreateAssistantRole(groupOwner, "default")).thenReturn(role); + + AssistantRole result = service.getOrCreateAssistantRole(groupOwner, "default"); + + assertThat(result).isSameAs(role); + } + + @Test + void shouldDispatchGetOrCreateAssistantRoleToUserServiceForUser() { + AssistantRole role = new AssistantRole(); + when(telegramUserService.getOrCreateAssistantRole(userOwner, "default")).thenReturn(role); + + AssistantRole result = service.getOrCreateAssistantRole(userOwner, "default"); + + assertThat(result).isSameAs(role); + } + + @Test + void shouldDispatchMenuVersionHashWriteByOwnerType() { + service.updateMenuVersionHash(groupOwner, "hash-g"); + service.updateMenuVersionHash(userOwner, "hash-u"); + + verify(telegramGroupService).updateMenuVersionHash(GROUP_CHAT_ID, "hash-g"); + verify(telegramUserService).updateMenuVersionHash(USER_TELEGRAM_ID, "hash-u"); + } + + @Test + void shouldReadMenuVersionHashByOwnerType() { + groupOwner.setMenuVersionHash("gh"); + userOwner.setMenuVersionHash("uh"); + + assertThat(service.menuVersionHashOf(groupOwner)).isEqualTo("gh"); + assertThat(service.menuVersionHashOf(userOwner)).isEqualTo("uh"); + } + + @Test + void shouldDispatchSetPreferredModelToGroupServiceForGroup() { + service.setPreferredModel(groupOwner, "openrouter/auto"); + + verify(telegramGroupService).updatePreferredModel(GROUP_CHAT_ID, "openrouter/auto"); + } + + @Test + void shouldSetPreferredModelInlineForUserAndTouchTimestamp() { + service.setPreferredModel(userOwner, "gpt-4o"); + + assertThat(userOwner.getPreferredModelId()).isEqualTo("gpt-4o"); + verify(telegramUserService).updateUserActivity(userOwner); + } + + @Test + void shouldClearPreferredModelByDelegatingToSetWithNull() { + service.clearPreferredModel(groupOwner); + + verify(telegramGroupService).updatePreferredModel(GROUP_CHAT_ID, null); + } + + @Test + void shouldReturnPreferredModelFromOwnerField() { + groupOwner.setPreferredModelId("meta/llama-3"); + + Optional result = service.getPreferredModel(groupOwner); + + assertThat(result).contains("meta/llama-3"); + } + + @Test + void shouldReturnEmptyPreferredModelWhenFieldIsBlank() { + userOwner.setPreferredModelId(" "); + + assertThat(service.getPreferredModel(userOwner)).isEmpty(); + } + + @Test + void shouldReturnEmptyPreferredModelWhenOwnerIsNull() { + assertThat(service.getPreferredModel(null)).isEmpty(); + } + + @Test + void shouldReturnTelegramIdByOwnerType() { + assertThat(service.telegramIdOf(groupOwner)).isEqualTo(GROUP_CHAT_ID); + assertThat(service.telegramIdOf(userOwner)).isEqualTo(USER_TELEGRAM_ID); + } + + @Test + void shouldThrowWhenOwnerTypeIsUnsupported() { + io.github.ngirchev.opendaimon.common.model.User stranger = + new io.github.ngirchev.opendaimon.common.model.User(); + assertThatThrownBy(() -> service.updateLanguageCode(stranger, "ru")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("updateLanguageCode"); + } +} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardServiceTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardServiceTest.java index 75c9a378..ea889bc3 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardServiceTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardServiceTest.java @@ -6,7 +6,7 @@ import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; -import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserRepository; +import io.github.ngirchev.opendaimon.common.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,14 +28,12 @@ class PersistentKeyboardServiceTest { private static final long USER_ID = 1L; - @Mock - private UserModelPreferenceService userModelPreferenceService; @Mock private CoreCommonProperties coreCommonProperties; @Mock private CoreCommonProperties.SummarizationProperties summarizationProperties; @Mock - private TelegramUserRepository telegramUserRepository; + private UserRepository userRepository; private PersistentKeyboardService service; @@ -59,16 +57,15 @@ void setUp() { TelegramUser user = new TelegramUser(); user.setLanguageCode("en"); - when(telegramUserRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(userModelPreferenceService.getPreferredModel(USER_ID)).thenReturn(Optional.empty()); + user.setPreferredModelId(null); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); service = new PersistentKeyboardService( - userModelPreferenceService, coreCommonProperties, botProvider, telegramProperties, messageLocalizationService, - telegramUserRepository); + userRepository); } /** diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java index ee333a9b..4c3f1e6b 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java @@ -36,12 +36,14 @@ class TelegramBotMenuServiceTest { private ObjectProvider telegramBotProvider; @Mock private ObjectProvider commandHandlersProvider; + @Mock + private ObjectProvider chatSettingsServiceProvider; private TelegramBotMenuService service; @BeforeEach void setUp() { - service = new TelegramBotMenuService(telegramBotProvider, commandHandlersProvider); + service = new TelegramBotMenuService(telegramBotProvider, commandHandlersProvider, chatSettingsServiceProvider); } @Test @@ -146,7 +148,7 @@ void shouldReconcileWhenHashIsNull() throws TelegramApiException { user.setLanguageCode("en"); user.setMenuVersionHash(null); - boolean changed = service.reconcileMenuIfStale(user); + boolean changed = service.reconcileMenuIfStale(user, user.getTelegramId()); assertThat(changed).isTrue(); verify(telegramBot).setMyCommands(anyList(), eq(4242L)); @@ -164,7 +166,7 @@ void shouldReconcileWhenHashDiffers() throws TelegramApiException { user.setLanguageCode("en"); user.setMenuVersionHash("stale-hash-from-an-older-deployment"); - boolean changed = service.reconcileMenuIfStale(user); + boolean changed = service.reconcileMenuIfStale(user, user.getTelegramId()); assertThat(changed).isTrue(); verify(telegramBot).setMyCommands(anyList(), eq(4242L)); @@ -185,7 +187,7 @@ void shouldSkipReconcileWhenHashMatches() throws TelegramApiException { user.setLanguageCode("en"); user.setMenuVersionHash(currentHash); - boolean changed = service.reconcileMenuIfStale(user); + boolean changed = service.reconcileMenuIfStale(user, user.getTelegramId()); assertThat(changed).isFalse(); verify(telegramBot, never()).setMyCommands(anyList(), any(Long.class)); @@ -199,7 +201,7 @@ void shouldSkipReconcileWhenLanguageCodeIsNull() throws TelegramApiException { user.setLanguageCode(null); user.setMenuVersionHash(null); - boolean changed = service.reconcileMenuIfStale(user); + boolean changed = service.reconcileMenuIfStale(user, user.getTelegramId()); assertThat(changed).isFalse(); verify(telegramBot, never()).setMyCommands(anyList(), any(Long.class)); diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java new file mode 100644 index 00000000..17ceb271 --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java @@ -0,0 +1,177 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import io.github.ngirchev.opendaimon.common.model.AssistantRole; +import io.github.ngirchev.opendaimon.common.model.ThinkingMode; +import io.github.ngirchev.opendaimon.common.service.AssistantRoleService; +import io.github.ngirchev.opendaimon.telegram.model.TelegramGroup; +import io.github.ngirchev.opendaimon.telegram.repository.TelegramGroupRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.telegram.telegrambots.meta.api.objects.Chat; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TelegramGroupServiceTest { + + private static final Long GROUP_CHAT_ID = -1001234567890L; + private static final boolean DEFAULT_AGENT_MODE_ENABLED = false; + + @Mock + private TelegramGroupRepository telegramGroupRepository; + @Mock + private AssistantRoleService assistantRoleService; + + private TelegramGroupService service; + + @BeforeEach + void setUp() { + service = new TelegramGroupService(telegramGroupRepository, assistantRoleService, DEFAULT_AGENT_MODE_ENABLED); + } + + @Test + void shouldCreateNewGroupWhenGetOrCreateCalledForUnknownChat() { + Chat chat = new Chat(); + chat.setId(GROUP_CHAT_ID); + chat.setTitle("DevOps team"); + chat.setType("supergroup"); + when(telegramGroupRepository.findByTelegramId(GROUP_CHAT_ID)).thenReturn(Optional.empty()); + when(telegramGroupRepository.save(any(TelegramGroup.class))).thenAnswer(inv -> inv.getArgument(0)); + + TelegramGroup result = service.getOrCreateGroup(chat); + + assertThat(result.getTelegramId()).isEqualTo(GROUP_CHAT_ID); + assertThat(result.getTitle()).isEqualTo("DevOps team"); + assertThat(result.getType()).isEqualTo("supergroup"); + assertThat(result.getIsBlocked()).isFalse(); + assertThat(result.getIsAdmin()).isFalse(); + assertThat(result.getAgentModeEnabled()).isEqualTo(DEFAULT_AGENT_MODE_ENABLED); + assertThat(result.getCreatedAt()).isNotNull(); + assertThat(result.getLanguageCode()).isNull(); // set lazily via /language + } + + @Test + void shouldReturnExistingGroupWithUpdatedMetadataWhenKnownChat() { + Chat chat = new Chat(); + chat.setId(GROUP_CHAT_ID); + chat.setTitle("Renamed group"); + chat.setType("supergroup"); + + TelegramGroup existing = new TelegramGroup(); + existing.setTelegramId(GROUP_CHAT_ID); + existing.setTitle("Old title"); + existing.setType("group"); + when(telegramGroupRepository.findByTelegramId(GROUP_CHAT_ID)).thenReturn(Optional.of(existing)); + when(telegramGroupRepository.save(any(TelegramGroup.class))).thenAnswer(inv -> inv.getArgument(0)); + + TelegramGroup result = service.getOrCreateGroup(chat); + + assertThat(result).isSameAs(existing); + assertThat(result.getTitle()).isEqualTo("Renamed group"); + assertThat(result.getType()).isEqualTo("supergroup"); + } + + @Test + void shouldThrowWhenGetOrCreateGroupReceivesNullChat() { + assertThatThrownBy(() -> service.getOrCreateGroup(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldNormaliseLanguageCodeAndPersistWhenUpdateLanguageCode() { + TelegramGroup group = new TelegramGroup(); + group.setTelegramId(GROUP_CHAT_ID); + when(telegramGroupRepository.findByTelegramId(GROUP_CHAT_ID)).thenReturn(Optional.of(group)); + when(telegramGroupRepository.save(any(TelegramGroup.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.updateLanguageCode(GROUP_CHAT_ID, "RU-ru"); + + assertThat(group.getLanguageCode()).isEqualTo("ru"); + } + + @Test + void shouldPersistAgentModeFlagWhenUpdateAgentMode() { + TelegramGroup group = new TelegramGroup(); + group.setTelegramId(GROUP_CHAT_ID); + when(telegramGroupRepository.findByTelegramId(GROUP_CHAT_ID)).thenReturn(Optional.of(group)); + + service.updateAgentMode(GROUP_CHAT_ID, true); + + assertThat(group.getAgentModeEnabled()).isTrue(); + } + + @Test + void shouldPersistThinkingModeWhenUpdateThinkingMode() { + TelegramGroup group = new TelegramGroup(); + group.setTelegramId(GROUP_CHAT_ID); + when(telegramGroupRepository.findByTelegramId(GROUP_CHAT_ID)).thenReturn(Optional.of(group)); + + service.updateThinkingMode(GROUP_CHAT_ID, ThinkingMode.SHOW_ALL); + + assertThat(group.getThinkingMode()).isEqualTo(ThinkingMode.SHOW_ALL); + } + + @Test + void shouldCreateAssistantRoleWhenGroupHasNoneYet() { + TelegramGroup group = new TelegramGroup(); + group.setTelegramId(GROUP_CHAT_ID); + AssistantRole defaultRole = new AssistantRole(); + defaultRole.setId(7L); + defaultRole.setVersion(1); + defaultRole.setContent("default"); + when(telegramGroupRepository.findByTelegramId(GROUP_CHAT_ID)).thenReturn(Optional.of(group)); + when(assistantRoleService.getOrCreateDefaultRole(group, "default content")).thenReturn(defaultRole); + + AssistantRole result = service.getOrCreateAssistantRole(group, "default content"); + + assertThat(result).isSameAs(defaultRole); + assertThat(group.getCurrentAssistantRole()).isSameAs(defaultRole); + } + + @Test + void shouldReturnExistingAssistantRoleWithoutCallingRoleServiceWhenGroupAlreadyHasOne() { + TelegramGroup group = new TelegramGroup(); + group.setTelegramId(GROUP_CHAT_ID); + AssistantRole existing = new AssistantRole(); + existing.setId(42L); + existing.setVersion(3); + existing.setContent("existing"); + group.setCurrentAssistantRole(existing); + when(telegramGroupRepository.findByTelegramId(GROUP_CHAT_ID)).thenReturn(Optional.of(group)); + + AssistantRole result = service.getOrCreateAssistantRole(group, "default content"); + + assertThat(result).isSameAs(existing); + } + + @Test + void shouldPersistPreferredModelWhenUpdatePreferredModel() { + TelegramGroup group = new TelegramGroup(); + group.setTelegramId(GROUP_CHAT_ID); + when(telegramGroupRepository.findByTelegramId(GROUP_CHAT_ID)).thenReturn(Optional.of(group)); + when(telegramGroupRepository.save(any(TelegramGroup.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.updatePreferredModel(GROUP_CHAT_ID, "openrouter/auto"); + + assertThat(group.getPreferredModelId()).isEqualTo("openrouter/auto"); + } + + @Test + void shouldPersistMenuVersionHashWhenUpdateMenuVersionHash() { + TelegramGroup group = new TelegramGroup(); + group.setTelegramId(GROUP_CHAT_ID); + when(telegramGroupRepository.findByTelegramId(GROUP_CHAT_ID)).thenReturn(Optional.of(group)); + + service.updateMenuVersionHash(GROUP_CHAT_ID, "deadbeef"); + + assertThat(group.getMenuVersionHash()).isEqualTo("deadbeef"); + } +} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramMessageServiceTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramMessageServiceTest.java index 252e3d6d..49ac8f25 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramMessageServiceTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramMessageServiceTest.java @@ -51,6 +51,10 @@ class TelegramMessageServiceTest { private ObjectProvider storagePropertiesProvider; @Mock private ObjectProvider selfProvider; + @Mock + private io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup chatOwnerLookup; + @Mock + private io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService chatSettingsService; private TelegramMessageService telegramMessageService; private TelegramUser telegramUser; @@ -67,7 +71,9 @@ void setUp() { messageLocalizationService, storagePropertiesProvider, conversationThreadService, - selfProvider); + selfProvider, + chatOwnerLookup, + chatSettingsService); when(selfProvider.getObject()).thenReturn(telegramMessageService); telegramUser = new TelegramUser(); telegramUser.setId(1L); @@ -75,7 +81,7 @@ void setUp() { thread.setId(50L); assistantRole = new AssistantRole(); assistantRole.setId(10L); - when(telegramUserService.getOrCreateAssistantRole(any(TelegramUser.class), any())).thenReturn(assistantRole); + when(chatSettingsService.getOrCreateAssistantRole(any(), any())).thenReturn(assistantRole); when(coreCommonProperties.getAssistantRole()).thenReturn("Default role"); when(conversationThreadService.getOrCreateThread(any(TelegramUser.class))).thenReturn(thread); } @@ -117,7 +123,7 @@ void saveUserMessage_withoutSession_noMetadata() { @Test void saveUserMessage_withCustomRole_usesCustomRole() { - when(telegramUserService.getOrCreateAssistantRole(eq(telegramUser), eq("Custom role"))) + when(chatSettingsService.getOrCreateAssistantRole(any(), eq("Custom role"))) .thenReturn(assistantRole); OpenDaimonMessage saved = new OpenDaimonMessage(); when(messageService.saveUserMessage(any(), any(), any(), eq(assistantRole), any(), any(), any(), any())) @@ -127,7 +133,7 @@ void saveUserMessage_withCustomRole_usesCustomRole() { telegramUser, null, "Hi", RequestType.TEXT, "Custom role", null); assertNotNull(result); - verify(telegramUserService).getOrCreateAssistantRole(telegramUser, "Custom role"); + verify(chatSettingsService).getOrCreateAssistantRole(any(), eq("Custom role")); } @Test @@ -222,7 +228,7 @@ void saveAssistantErrorMessage_usesRoleAndCallsMessageService() { @Test void saveAssistantErrorMessage_withCustomRole_usesCustomRole() { - when(telegramUserService.getOrCreateAssistantRole(eq(telegramUser), eq("Custom"))) + when(chatSettingsService.getOrCreateAssistantRole(any(), eq("Custom"))) .thenReturn(assistantRole); OpenDaimonMessage saved = new OpenDaimonMessage(); when(messageService.saveAssistantErrorMessage(any(), any(), any(), eq(assistantRole), any(), any())) @@ -231,6 +237,6 @@ void saveAssistantErrorMessage_withCustomRole_usesCustomRole() { telegramMessageService.saveAssistantErrorMessage( telegramUser, "Err", "svc", "Custom", "data"); - verify(telegramUserService).getOrCreateAssistantRole(telegramUser, "Custom"); + verify(chatSettingsService).getOrCreateAssistantRole(any(), eq("Custom")); } } From 91d63e0160770df39737e1899ac3ea0c993d5ef4 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Sat, 25 Apr 2026 07:04:43 +0000 Subject: [PATCH 11/14] Fix bug --- observability-agent.md | 48 +++++++++++++++++++ .../service/TelegramBotMenuService.java | 3 +- .../service/TelegramGroupService.java | 3 +- .../service/TelegramBotMenuServiceTest.java | 13 +++-- .../service/TelegramGroupServiceTest.java | 2 +- 5 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 observability-agent.md diff --git a/observability-agent.md b/observability-agent.md new file mode 100644 index 00000000..f0534280 --- /dev/null +++ b/observability-agent.md @@ -0,0 +1,48 @@ +# Observability Agent Instructions + +## Services & Ports + +| Service | Port | Container | Log Command | +|---------|------|-----------|-------------| +| opendaimon-app | 8080 | open-daimon-app | `docker logs -f open-daimon-app` | +| postgres | 5432 | open-daimon-postgres | `docker logs -f open-daimon-postgres` | +| elasticsearch | 9200 | open-daimon-elasticsearch | `docker logs -f open-daimon-elasticsearch` | +| kibana | 5601 | open-daimon-kibana | `docker logs -f open-daimon-kibana` | +| logstash | 5044 | open-daimon-logstash | `docker logs -f open-daimon-logstash` | +| prometheus | 9090 | open-daimon-prometheus | `docker logs -f open-daimon-prometheus` | +| grafana | 3000 | open-daimon-grafana | `docker logs -f open-daimon-grafana` | +| minio | 9000/9001 | open-daimon-minio | `docker logs -f open-daimon-minio` | + +## Quick Commands + +```bash +# All running containers +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + +# Follow app logs +docker logs -f open-daimon-app + +# App logs (last 100 lines) +docker logs --tail 100 -f open-daimon-app + +# All services tail +docker-compose logs --tail=50 + +# Specific service +docker-compose logs -f opendaimon-app + +# Search logs +docker logs open-daimon-app 2>&1 | grep -i "exception\|failed" + +# Elasticsearch health +curl -s http://localhost:9200/_cluster/health?pretty + +# Prometheus targets +curl -s http://localhost:9090/api/v1/targets | jq '.data.activeTargets[] | .labels.job' +``` + +## Dashboards + +- **Grafana**: http://localhost:3000 (admin/admin123456) +- **Kibana**: http://localhost:5601 +- **Prometheus**: http://localhost:9090 \ No newline at end of file diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java index ab9aedb0..f963b023 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuService.java @@ -152,8 +152,7 @@ public boolean reconcileMenuIfStale(User owner, Long chatId) { } String languageCode = owner.getLanguageCode(); if (languageCode == null) { - // Owner is still on Default-scope menu; startup refresh already covers them. - return false; + languageCode = SupportedLanguages.DEFAULT_LANGUAGE; } String currentHash = getCurrentMenuVersionHash(); String storedHash = menuVersionHashOf(owner); diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java index 93b55fe9..28ffee16 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; import org.telegram.telegrambots.meta.api.objects.Chat; +import io.github.ngirchev.opendaimon.common.SupportedLanguages; import io.github.ngirchev.opendaimon.common.model.AssistantRole; import io.github.ngirchev.opendaimon.common.model.ThinkingMode; import io.github.ngirchev.opendaimon.common.service.AssistantRoleService; @@ -136,7 +137,7 @@ private TelegramGroup createGroupInner(Chat chat) { group.setIsBlocked(false); group.setIsAdmin(false); group.setIsPremium(false); - group.setLanguageCode(null); + group.setLanguageCode(SupportedLanguages.DEFAULT_LANGUAGE); group.setAgentModeEnabled(defaultAgentModeEnabled); TelegramGroup saved = telegramGroupRepository.save(group); log.info("Telegram group created: id={}, chatId={}, title='{}', type={}", diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java index 4c3f1e6b..53b18e39 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramBotMenuServiceTest.java @@ -195,7 +195,11 @@ void shouldSkipReconcileWhenHashMatches() throws TelegramApiException { } @Test - void shouldSkipReconcileWhenLanguageCodeIsNull() throws TelegramApiException { + void shouldReconcileWithDefaultLanguageWhenLanguageCodeIsNull() throws TelegramApiException { + when(telegramBotProvider.getObject()).thenReturn(telegramBot); + TelegramSupportedCommandProvider h1 = lang -> "/start - Start"; + when(commandHandlersProvider.orderedStream()).thenAnswer(inv -> Stream.of(h1)); + TelegramUser user = new TelegramUser(); user.setTelegramId(4242L); user.setLanguageCode(null); @@ -203,9 +207,8 @@ void shouldSkipReconcileWhenLanguageCodeIsNull() throws TelegramApiException { boolean changed = service.reconcileMenuIfStale(user, user.getTelegramId()); - assertThat(changed).isFalse(); - verify(telegramBot, never()).setMyCommands(anyList(), any(Long.class)); - verify(telegramBot, never()).setMyCommands(anyList(), any(String.class)); - assertThat(user.getMenuVersionHash()).isNull(); + assertThat(changed).isTrue(); + verify(telegramBot).setMyCommands(anyList(), eq(4242L)); + assertThat(user.getMenuVersionHash()).isNotNull(); } } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java index 17ceb271..5b3f12b7 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java @@ -55,7 +55,7 @@ void shouldCreateNewGroupWhenGetOrCreateCalledForUnknownChat() { assertThat(result.getIsAdmin()).isFalse(); assertThat(result.getAgentModeEnabled()).isEqualTo(DEFAULT_AGENT_MODE_ENABLED); assertThat(result.getCreatedAt()).isNotNull(); - assertThat(result.getLanguageCode()).isNull(); // set lazily via /language + assertThat(result.getLanguageCode()).isEqualTo("en"); // default language on creation } @Test From 5d2211bb19d46322debde58665d7feec441506c7 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Sat, 25 Apr 2026 11:51:26 +0300 Subject: [PATCH 12/14] Fix tests --- TODO.md | 17 ++++++++++------- .../it/telegram/TelegramGroupEntityIT.java | 4 +++- .../MessageTelegramCommandHandlerIT.java | 9 ++++----- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index e5e8c055..d63b65b3 100644 --- a/TODO.md +++ b/TODO.md @@ -41,12 +41,13 @@ - [x] Agent observability: intermediate events (thinking, tool_call, observation) shown in Telegram - [x] Agent final answer: stream by paragraphs (like gateway path) instead of single message - [x] Ollama thinking: parse `...` tags from getText() and show as reasoning content - - [ ] OpenRouter reasoning: verify `reasoningContent` in Generation metadata for models with extended thinking + - [x] OpenRouter reasoning: verify `reasoningContent` in Generation metadata for models with extended thinking — `AgentTextSanitizer.extractReasoning` reads `metadata.get("reasoningContent")` (opendaimon-spring-ai/.../agent/AgentTextSanitizer.java:89) - [ ] Show thinking in web - [ ] Provider Registry — replace ProviderType enum with String + Strategy pattern ([plan](docs/provider-registry-plan.md)) - [ ] Different models in the flow - [ ] Add balance loader -- [ ] WebTools need to parse result +- [x] WebTools need to parse result — JSoup-based HTML parsing in `WebTools.java:5,173` strips markup and returns clean text to the model +- [ ] Arch unit ## Agent Framework Pivot @@ -111,11 +112,11 @@ - [ ] **REST Integration** — agent endpoint for REST/UI ## Bugs -- [ ] Bug - custom role for group chat is not working +- [x] Bug - custom role for group chat is not working — closed by TelegramGroup migration (Stage 4): `RoleTelegramCommandHandler` writes role to the resolved `User owner` (TelegramGroup in groups, TelegramUser in privates) via `chatSettingsService.updateAssistantRole(owner, ...)`; `TelegramMessageService` reads the role from the same owner via `ChatOwnerLookup.findByChatId(thread.scopeId)`. - [ ] Bug 2026-04-11 10:56:21.190 [opendaimon_bot Telegram Connection] ERROR o.t.t.u.DefaultBotSession - api.telegram.org 2026-04-11T10:56:21.190938830Z java.net.UnknownHostException: api.telegram.org 2026-04-11T10:56:21.190941994Z at java.base/java.net.InetAddress$CachedLookup.get(Unknown Source)... -- [ ] Bug for summarizing in group chat 2026-04-11 07:20:05.388 [boundedElastic-20] ERROR i.g.n.o.a.s.s.SpringAIChatService - Spring AI stream error. model=openrouter/auto, body={reasoning={max_tokens=1500}} +- [x] Bug for summarizing in group chat 2026-04-11 07:20:05.388 [boundedElastic-20] ERROR i.g.n.o.a.s.s.SpringAIChatService - Spring AI stream error. model=openrouter/auto, body={reasoning={max_tokens=1500}} 2026-04-11T07:20:05.389665794Z io.github.ngirchev.opendaimon.common.exception.SummarizationFailedException: Conversation summarization failed. Please start a new session (/newthread). 2026-04-11T07:20:05.389668410Z at io.github.ngirchev.opendaimon.ai.springai.memory.SummarizingChatMemory.performSummarizationAndUpdateChatMemory(SummarizingChatMemory.java:189) 2026-04-11T07:20:05.389670903Z at io.github.ngirchev.opendaimon.ai.springai.memory.SummarizingChatMemory.get(SummarizingChatMemory.java:93) @@ -178,15 +179,17 @@ 2026-04-11T07:20:05.389844438Z at org.springframework.web.client.DefaultRestClient$DefaultResponseSpec.executeAndExtract(DefaultRestClient.java:814) 2026-04-11T07:20:05.389846893Z at org.springframework.web.client.DefaultRestClient$DefaultResponseSpec.body(DefaultRestClient.java:750) 2026-04-11T07:20:05.389849206Z at org.springframework.ai.ollama.api.OllamaApi.chat(OllamaApi.java:115) - Also message was sent to personal chat instead of group + - **Closed by Stage 6** of the TelegramGroup migration: `SummarizationService` now resolves the chat-scoped owner via the new `ChatOwnerLookup` SPI (`thread.getScopeId()` → `TelegramChatOwnerLookup.findByChatId`) and seeds the owner's `preferredModelId` into `ChatAICommand.metadata` BEFORE the gateway dispatches the request. This eliminates the AUTO-routing path that produced an empty `model` field and the resulting HTTP 400. The "personal chat instead of group" symptom was a side-effect of cross-bleed: the bot was reading the invoker's settings (role / model / language) inside a group, making the group response look like a private-chat reply — the same Stage 4 settings-owner refactor closes it. + - Regression test: `SummarizationServiceTest.shouldSeedPreferredModelFromChatOwnerIntoSummarizationMetadata` (uses real `ChatOwnerLookup` lambda + `ArgumentCaptor` to assert `PREFERRED_MODEL_ID_FIELD` lands in the dispatched `ChatAICommand.metadata`). - [x] Bug: WebTools.fetchUrl 403 Forbidden on Medium/Cloudflare sites — add browser-like fetch headers plus Cloudflare-challenge retry and per-run agent guard -- [ ] Bug: WebTools.fetchUrl DataBufferLimitException → model responds in English (2026-04-11) +- [x] Bug: WebTools.fetchUrl DataBufferLimitException → model responds in English (2026-04-11) - `WebClient` default buffer limit is 256KB (262144 bytes); large pages (e.g. GitHub issues) exceed it - `fetchUrl` catches the exception and returns empty string `""` - Model receives empty tool result, generates a fallback response ignoring the language instruction - Root cause: `SpringAIAutoConfig.webClient()` creates WebClient via `builder.build()` without `maxInMemorySize` - Observed: `google/gemini-2.5-flash-lite` via `openrouter/auto` responded in English despite `languageCode=ru` - - Fix 1: Set `maxInMemorySize` in `SpringAIAutoConfig.webClient()` (e.g. 2MB) - - Fix 2: Investigate why language instruction (`"Prefer responding in Russian"`) is lost after tool call failure — check if system message is preserved in the retry/fallback path + - **Fix 1 LANDED**: `maxInMemorySize(2 * 1024 * 1024)` set on the WebClient builder (`SpringAIAutoConfig.java:254`, comment at line 231 explaining the 2 MB cap). + - Fix 2 (open follow-up): why language instruction is lost after a tool-call failure — separate from the buffer issue and not addressed here. - Log: `WebTools.fetchUrl failed for url=[https://github.com/anthropics/claude-code/issues/42796]: DataBufferLimitException: Exceeded limit on max bytes to buffer : 262144` ## Tech Debt diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java index c686c034..6fa2aa87 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java @@ -1,5 +1,6 @@ package io.github.ngirchev.opendaimon.it.telegram; +import io.github.ngirchev.opendaimon.common.SupportedLanguages; import io.github.ngirchev.opendaimon.common.config.CoreCommonProperties; import io.github.ngirchev.opendaimon.common.config.CoreFlywayConfig; import io.github.ngirchev.opendaimon.common.config.CoreJpaConfig; @@ -170,7 +171,8 @@ void shouldLazilyCreateGroupOnFirstInteraction() { assertEquals(chatId, groupOwner.getTelegramId()); assertEquals("Fresh team", groupOwner.getTitle()); assertEquals("supergroup", groupOwner.getType()); - assertNull(groupOwner.getLanguageCode(), "language is unset until /language runs"); + assertEquals(SupportedLanguages.DEFAULT_LANGUAGE, groupOwner.getLanguageCode(), + "language defaults to DEFAULT_LANGUAGE on creation; /language can override later"); assertNull(groupOwner.getPreferredModelId(), "model is unset until /model runs"); Optional found = telegramGroupRepository.findByTelegramId(chatId); diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java index 80521c8c..20f53774 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java @@ -8,7 +8,6 @@ import io.github.ngirchev.opendaimon.common.repository.UserRepository; import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsOwnerResolver; import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; -import io.github.ngirchev.opendaimon.telegram.service.TelegramChatOwnerLookup; import io.github.ngirchev.opendaimon.telegram.service.TelegramGroupService; import io.github.ngirchev.opendaimon.telegram.repository.TelegramGroupRepository; import io.github.ngirchev.opendaimon.common.storage.service.FileStorageService; @@ -304,10 +303,10 @@ public ChatSettingsOwnerResolver chatSettingsOwnerResolver( return new ChatSettingsOwnerResolver(telegramUserService, telegramGroupService); } - @Bean - public ChatOwnerLookup chatOwnerLookup(ChatSettingsOwnerResolver resolver) { - return new TelegramChatOwnerLookup(resolver); - } + // ChatOwnerLookup intentionally not overridden here — NOOP fallback from + // CoreAutoConfig is used. Overriding would cause BeanDefinitionOverrideException + // because @Import-loaded @Configuration is not subject to auto-config ordering + // that makes @ConditionalOnMissingBean defer to user-defined beans. @Bean @Primary From b0dc300798bf7d3924b37ebc11915d775f8a944d Mon Sep 17 00:00:00 2001 From: Nikolay Girchev Date: Sat, 25 Apr 2026 17:30:31 +0300 Subject: [PATCH 13/14] fsm-5-2-attachment-fix (#26) * Fix attaches * Fix attachment * Updated docs --- .claude/rules/java/fixture-tests.md | 1 + .claude/rules/java/testing.md | 59 +++++ docs/usecases/agent-image-attachment.md | 115 +++++++++ .../fixture/TelegramAgentImageFixtureIT.java | 140 +++++++++++ .../manual/AgentModeOpenRouterManualIT.java | 63 ++++- .../opendaimon/common/agent/AgentContext.java | 22 ++ .../opendaimon/common/agent/AgentRequest.java | 26 +- opendaimon-spring-ai/SPRING_AI_MODULE.md | 55 ++++ .../agent/DefaultAgentOrchestrator.java | 3 + .../agent/PlanAndExecuteAgentExecutor.java | 5 + .../ai/springai/agent/ReActAgentExecutor.java | 6 +- .../springai/agent/SimpleChainExecutor.java | 45 +++- .../agent/SpringAgentLoopActions.java | 52 +++- .../agent/SimpleChainExecutorTest.java | 52 ++++ ...SpringAgentLoopActionsAttachmentsTest.java | 167 +++++++++++++ opendaimon-telegram/TELEGRAM_MODULE.md | 13 + .../fsm/TelegramMessageHandlerActions.java | 28 ++- ...elegramMessageHandlerActionsAgentTest.java | 234 ++++++++++++++++++ 18 files changed, 1076 insertions(+), 10 deletions(-) create mode 100644 docs/usecases/agent-image-attachment.md create mode 100644 opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/TelegramAgentImageFixtureIT.java create mode 100644 opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActionsAttachmentsTest.java diff --git a/.claude/rules/java/fixture-tests.md b/.claude/rules/java/fixture-tests.md index ffc7fcd2..e55835ad 100644 --- a/.claude/rules/java/fixture-tests.md +++ b/.claude/rules/java/fixture-tests.md @@ -11,6 +11,7 @@ paths: - `auto-mode-model-selection.md` -> `AutoModeModelSelectionFixtureIT` - `text-pdf-rag.md` -> `TextPdfRagFixtureIT` - `image-pdf-vision-cache.md` -> `ImagePdfVisionCacheFixtureIT` +- `agent-image-attachment.md` -> `TelegramAgentImageFixtureIT` Before modifying fixture tests, read the corresponding use case doc from `docs/usecases/`. diff --git a/.claude/rules/java/testing.md b/.claude/rules/java/testing.md index 59a0aebe..54122115 100644 --- a/.claude/rules/java/testing.md +++ b/.claude/rules/java/testing.md @@ -10,9 +10,68 @@ paths: 2. Target 80%+ line coverage (JaCoCo) 3. Focus on service and domain logic — skip trivial getters/config classes +## Mandatory Test Coverage + +Every bug fix and every new feature is incomplete without a test that pins the new behavior: + +- **Bug fix** — add a regression test that fails on the original code and passes after the fix. Place it next to the existing tests of the modified service, name it `shouldDoXWhenY` describing the corrected behavior, and reference the originating review comment / issue in a brief comment so the intent survives future refactors. +- **New feature** — add unit tests for each new public method on the service layer. If the feature carries data into an LLM (vision, RAG, tool-calling, conversation memory), follow the layering rule below: unit + fixture IT minimum, plus a manual IT when an LLM round-trip is the only proof the wiring works. +- **No test, no merge.** A change that only edits production code without test coverage is not finished — even if it compiles and the manual smoke check passes. The test is the artifact that prevents the same bug from coming back six months later when the surrounding code has shifted. + ## Project Conventions - **JUnit 5** + **AssertJ** + **Mockito** + **Testcontainers** - Test naming: `shouldDoSomethingWhenCondition` - Mirror `src/main/java` package structure in `src/test/java` - Fix implementation, not tests (unless tests are wrong) + +## Maven multi-module gotcha + +When you change a class in a shared module (e.g. `opendaimon-common`) and run +tests in a downstream module, **always pass `-am` (also-make)**: + +```sh +./mvnw test -pl opendaimon-spring-ai -am -Dtest=MyTest +``` + +Without `-am`, Maven uses the previously-installed JAR / `target/classes` of +the upstream module and silently runs tests against the **stale** version of +the changed class. Symptom: compile errors like + +``` +constructor MyClass cannot be applied to given types; + required: 5 args; found: 6 args +``` + +even though the source file in the upstream module clearly has the 6-arg +constructor — Maven just hasn't recompiled it. + +When in doubt, run `./mvnw clean compile` over the whole reactor first, then +the targeted `test -pl ... -am` run. + +Also, when targeting a single test in a multi-module build, surefire fails on +sibling modules where that test name does not exist. Add +`-Dsurefire.failIfNoSpecifiedTests=false` to make surefire skip those modules +quietly instead of failing the build. + +## Test layers — when to use what + +The project keeps three layers of tests; pick the right one before you start +writing. + +| Layer | Path | Models | When | +|---|---|---|---| +| **Unit** | `*/src/test/java/**` | mocks (`when(chatModel.stream(...))`) | Every public method on a service. Fast, deterministic, runs on every commit. | +| **Fixture IT** | `opendaimon-app/src/it/java/**/fixture/` (`@Tag("fixture")`) | mocks or deterministic stubs | One per use case in `docs/usecases/`. Wires real Spring components together but never calls a real LLM — keeps `-Pfixture` fast and reliable. | +| **Manual IT** | `opendaimon-app/src/it/java/**/manual/` (`@Tag("manual")` + `@EnabledIfSystemProperty(...)`) | **real Ollama** (local) and/or **real OpenRouter** | End-to-end behavior of the same use case against a real LLM. Both flavors are usually present in pairs (`*OllamaManualIT`, `*OpenRouterManualIT`). Not in CI. | + +Rule of thumb: if a use case carries data through to an LLM (vision, RAG, +tool-calling, conversation memory), it needs a manual IT in addition to the +unit + fixture coverage. Mocks pass the test even when the production wiring +silently drops the data; only a real LLM proves the model actually received it. + +When the use case targets a vision-capable code path, prefer **OpenRouter** +with an explicit vision model (`z-ai/glm-4.5v`, `google/gemini-2.5-flash-preview`) +over `openrouter/auto` — auto-routing picks unpredictable models and produces +flaky test results. The Ollama variant should use a small local vision model +(`gemma3:4b`) and gate on `manual.ollama.e2e=true`. diff --git a/docs/usecases/agent-image-attachment.md b/docs/usecases/agent-image-attachment.md new file mode 100644 index 00000000..a9cdf11b --- /dev/null +++ b/docs/usecases/agent-image-attachment.md @@ -0,0 +1,115 @@ +# Agent Path: Image Attachment Propagation + +> **Fixture test:** `TelegramAgentImageFixtureIT` — run with `./mvnw clean verify -pl opendaimon-app -am -Pfixture` +> +> **Unit tests:** +> - `SpringAgentLoopActionsAttachmentsTest` — agent path (ReAct/think) media injection +> - `SimpleChainExecutorTest#shouldAttachImageMediaToUserMessageWhenAttachmentsHasImage` — simple-chain path +> - `TelegramMessageHandlerActionsAgentTest#shouldPassAttachmentsToAgentRequestWhenCommandHasImage` — caller wiring + +## Why this exists + +When a user uploads a photo with a caption in Telegram and the chat is in **agent mode** +(ReAct/thinking enabled), the routing predicate sends the request to +`AgentExecutor.executeStream(AgentRequest)` instead of the gateway path. Before this +use case was covered, `AgentRequest` had no `attachments` field — the image was already +materialised in `TelegramCommand.attachments()` (verified by logs: `Photo processed for +user 2: key=photo/...`) and `DefaultAICommandFactory` correctly resolved +`requiredCaps=[AUTO, VISION]` and routed to a vision-capable model +(`z-ai/glm-4.5v`), but the bytes never reached the prompt: + +``` +Agent think: raw prompt messages +[USER] что тут? +[CHAT history…] +``` + +No `image_url`, no `Media`. The vision model would politely answer +"уточните, есть ли у вас изображение?" — closing the loop with the user staring at +a missing image. + +The gateway path (`SpringAIGateway` + `SpringDocumentPreprocessor`) already did this +correctly by building `UserMessage.builder().text(...).media(mediaList).build()`. The +agent path was a parallel implementation that forgot the media step. + +## Flow (agent path with image) + +```mermaid +sequenceDiagram + actor User + participant TG as TelegramFileService + participant CF as DefaultAICommandFactory + participant MH as TelegramMessageHandlerActions + participant AE as ReActAgentExecutor + participant LA as SpringAgentLoopActions + participant LLM as Vision-capable Chat Model + + User->>TG: photo + caption «что тут?» (group chat with self-mention) + TG->>TG: download bytes, persist key=photo/ + TG->>CF: prepareCommand → AICommand with attachments + requiredCaps=[AUTO, VISION] + CF->>MH: AICommand{attachments=[Attachment(IMAGE, image/png, bytes)]} + + MH->>MH: route to agent path (agentExecutor present, agent mode ON) + MH->>AE: AgentRequest(task, threadKey, metadata, maxIter, tools, strategy, attachments) + + AE->>AE: build AgentContext(..., attachments) + AE->>LA: think(ctx) + + LA->>LA: messages = getOrCreateHistory(ctx) // empty on first iteration + LA->>LA: SystemMessage + loadConversationHistory + buildInitialUserMessage(ctx) + Note over LA: buildInitialUserMessage:
filter ctx.attachments by IMAGE,
convert to List,
UserMessage.builder().text(...).media(...).build() + + LA->>LLM: stream(Prompt with multimodal UserMessage) + LLM-->>LA: «На фото — кошка» + + LA-->>AE: FINAL_ANSWER + AE-->>MH: stream of AgentStreamEvent + MH-->>User: «На фото — кошка» +``` + +## Invariants + +1. **Image attachments propagate end-to-end.** Source of attachments at the + Telegram → agent boundary is the pipeline-processed list on the AI command — + `ChatAICommand.attachments()` on the default path, or + `FixedModelChatAICommand.attachments()` when the chat has a preferred model + fixed (`DefaultAICommandFactory` returns the latter shape in that case). + Fallback to `TelegramCommand.attachments()` is used **only** when the AI + command does not carry a processed list. Mirrors `SpringAIGateway.java:383-387`. + This matters for image-only PDFs: `AIRequestPipeline` renders each PDF page + into an IMAGE attachment in `mutableAttachments`, and the agent path must + read those rendered pages — not the raw PDF that `toImageMedia()` would + discard as non-IMAGE. The fixed-model case is the same flow with a different + command shape: skipping the `FixedModelChatAICommand` branch silently regresses + to raw PDF bytes whenever the user has a preferred model selected. + Chain: `ChatAICommand.attachments()` / `FixedModelChatAICommand.attachments()` + → `AgentRequest.attachments()` → `AgentContext.getAttachments()` → first + `UserMessage.media` in the prompt. Any link broken silently degrades vision + queries to text-only. +2. **Only IMAGE-typed attachments cross the boundary.** PDFs and other documents + go through the gateway RAG path (`SpringDocumentPreprocessor`); they are + intentionally filtered out of the agent prompt. +3. **Media is attached once, on the first user message of the run.** ReAct loops + reuse the same `messages` list across `think()` iterations + (`KEY_CONVERSATION_HISTORY` extras key); subsequent iterations append assistant + and tool messages without rebuilding from scratch, so the original + `UserMessage(media)` survives every prompt rebuild. +4. **Tool-result UserMessages stay plain-text.** The follow-up `UserMessage` created + for `ToolResponseMessage` propagation is intentionally without media — the image + is already in the conversation context above it. +5. **SimpleChain executor mirrors the same shape.** Strategy=SIMPLE goes through + `SimpleChainExecutor`, not `ReActAgentExecutor`, but it uses the same + `buildUserMessage`-with-media helper so caption-only photos in non-ReAct flows + also work. +6. **Plan-and-execute sub-tasks do NOT inherit attachments.** Sub-steps of a + decomposed plan are textual and run with `attachments=List.of()`; if a future + product requirement needs an image to flow into a specific plan step, see the + TODO in `PlanAndExecuteAgentExecutor`. + +## Out of scope + +- Persisting media in `ChatMemory` for cross-turn recall — the current + implementation only carries the image into the *current* run; on the next user + turn, the previous image is not auto-resurrected from history. +- The unrelated `400 "text must be non-empty"` from a status-message edit + (visible in the same prod log block) — separate bug, separate ticket. diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/TelegramAgentImageFixtureIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/TelegramAgentImageFixtureIT.java new file mode 100644 index 00000000..ea8aa91c --- /dev/null +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/TelegramAgentImageFixtureIT.java @@ -0,0 +1,140 @@ +package io.github.ngirchev.opendaimon.it.fixture; + +import io.github.ngirchev.opendaimon.ai.springai.agent.SpringAgentLoopActions; +import io.github.ngirchev.opendaimon.common.agent.AgentContext; +import io.github.ngirchev.opendaimon.common.model.Attachment; +import io.github.ngirchev.opendaimon.common.model.AttachmentType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; +import org.springframework.ai.model.tool.ToolCallingManager; +import reactor.core.publisher.Flux; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Fixture test for use case: agent-image-attachment.md + * + *

Pins the invariant from the prod log of 2026-04-25 (chatId=-5267226692, + * caption «что тут?», requiredCaps=[AUTO, VISION], resolved=z-ai/glm-4.5v): when a + * user sends a captioned photo into a chat that is in agent mode with + * thinking enabled, the photo bytes must reach the LLM as multimodal + * {@link Media} on the first {@link UserMessage} of the agent prompt — not as + * plain text. Before this use case was covered, all unit tests passed and the + * bug regressed silently into production. + * + *

Intentionally lightweight: this fixture does not bring up a Spring + * context. It instantiates {@link SpringAgentLoopActions} directly — the same + * production class that lives behind {@code ReActAgentExecutor.execute()} — and + * verifies the prompt shape by capturing the {@link Prompt} sent to the + * {@link ChatModel}. End-to-end Spring wiring of the agent FSM is covered by + * the manual ITs ({@code AgentModeOpenRouterManualIT}, {@code AgentModeOllamaManualIT}). + * + *

Tagged {@code @Tag("fixture")} so it runs under {@code -Pfixture}. + */ +@Tag("fixture") +class TelegramAgentImageFixtureIT { + + private static final byte[] PNG_MAGIC = + new byte[]{(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10, 0, 0, 0, 0}; + + @Test + @DisplayName("Agent path with thinking — captioned photo reaches LLM as Media on first user message") + void shouldRouteImageAttachmentIntoFirstUserMessageWhenAgentPathWithThinking() { + ChatModel chatModel = mock(ChatModel.class); + ChatResponse finalAnswer = new ChatResponse(List.of( + new Generation(new AssistantMessage("На фото — кошка")))); + when(chatModel.stream(any(Prompt.class))).thenReturn(Flux.just(finalAnswer)); + + SpringAgentLoopActions actions = new SpringAgentLoopActions( + chatModel, + mock(ToolCallingManager.class), + List.of(), + null, + Duration.ofSeconds(30)); + + // Reproduces the prod payload: caption "что тут?" + a single image attachment, + // routed through the agent strategy. Group-chat scope id is irrelevant for the + // multimodal-prompt invariant — the attachment lives in the AgentContext, not + // in the chat metadata. + Attachment photo = new Attachment( + "photo/1c92c98f-fixture", "image/png", "photo.png", + PNG_MAGIC.length, AttachmentType.IMAGE, PNG_MAGIC); + AgentContext ctx = new AgentContext( + "что тут?", "fixture-thread", Map.of(), 5, Set.of(), + List.of(photo)); + + actions.think(ctx); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Prompt.class); + verify(chatModel).stream(captor.capture()); + + UserMessage firstUserMessage = captor.getValue().getInstructions().stream() + .filter(m -> m.getMessageType() == MessageType.USER) + .map(UserMessage.class::cast) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Prompt has no UserMessage — the agent path must build at least one")); + + assertThat(firstUserMessage.getMedia()) + .as("Vision-capable model must receive the image bytes — see use case " + + "agent-image-attachment.md, regression of prod log 2026-04-25 08:38:48") + .hasSize(1); + Media media = firstUserMessage.getMedia().getFirst(); + assertThat(media.getMimeType().toString()).isEqualTo("image/png"); + assertThat(firstUserMessage.getText()) + .as("Caption text must travel alongside media, not be replaced by it") + .contains("что тут?"); + } + + @Test + @DisplayName("Agent path — text-only message still produces a plain-text user message") + void shouldKeepUserMessagePlainTextWhenNoAttachmentsArePresent() { + ChatModel chatModel = mock(ChatModel.class); + when(chatModel.stream(any(Prompt.class))).thenReturn(Flux.just( + new ChatResponse(List.of(new Generation(new AssistantMessage("hi")))))); + + SpringAgentLoopActions actions = new SpringAgentLoopActions( + chatModel, + mock(ToolCallingManager.class), + List.of(), + null, + Duration.ofSeconds(30)); + + AgentContext ctx = new AgentContext( + "hello", "fixture-thread", Map.of(), 5, Set.of(), List.of()); + + actions.think(ctx); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Prompt.class); + verify(chatModel).stream(captor.capture()); + + UserMessage firstUserMessage = captor.getValue().getInstructions().stream() + .filter(m -> m.getMessageType() == MessageType.USER) + .map(UserMessage.class::cast) + .findFirst() + .orElseThrow(); + + assertThat(firstUserMessage.getMedia()) + .as("Without image attachments the prompt must remain plain-text") + .isEmpty(); + } +} diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java index 8a5f28f2..295871bd 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java @@ -492,6 +492,62 @@ void admin_agentReact_respondsInRussian_whenLanguageCodeIsRu() { .matches("(?s).*[\\p{IsCyrillic}]+.*"); } + // --- B5: Agent path with image attachment + vision model (regression for prod 2026-04-25) --- + + @Test + @Timeout(3 * 60) + @DisplayName("B5: ADMIN agent + image + vision model — model sees the picture, not just the caption") + void admin_agentReact_imageAttachment_visionDescribesObjects() throws IOException { + // Reproduces prod log of 2026-04-25 (chatId=-5267226692, caption «что тут?», resolved=z-ai/glm-4.5v): + // before the fix, AgentRequest had no attachments field, so the photo bytes were dropped before + // the prompt was built and the vision model would answer "укажите изображение". This test fires + // the exact agent-path code that ships to prod and asserts the model actually describes the photo. + // + // Pin to z-ai/glm-4.5v (the model that misbehaved in prod) so the test covers the same routing + // decision the user hit — not a different vision model picked by openrouter/auto. + userModelPreferenceService.setPreferredModel(ADMIN_CHAT_ID, "z-ai/glm-4.5v"); + + io.github.ngirchev.opendaimon.common.model.Attachment image = loadImageAttachment(); + TelegramCommand command = createMessageCommand( + ADMIN_CHAT_ID, 5, + "Опиши что ты видишь на этом фото", + "ru", + List.of(image)); + + messageHandler.handle(command); + + TelegramUser user = telegramUserRepository.findByTelegramId(ADMIN_CHAT_ID) + .orElseThrow(() -> new IllegalStateException("Telegram user should be created")); + ConversationThread thread = threadRepository.findMostRecentActiveThread(user) + .orElseThrow(() -> new IllegalStateException("Active thread should exist")); + String reply = latestAssistantReply(thread); + + assertThat(reply) + .as("Agent must produce a non-blank response when an image is attached") + .isNotBlank(); + + // The image (attachments/objects.jpeg) shows a pink bunny + flowers on sticks. If the agent + // path lost the image, the model would either ask for clarification or talk about something + // unrelated. Any of the visible objects appearing in the reply confirms the model received + // multimodal Media on the first user message of the agent prompt. + assertThat(reply.toLowerCase()) + .as("Vision model should describe an object from the picture (bunny / rabbit / flowers / leaves) — " + + "if the reply asks 'where is the image?' the agent path lost the attachment again") + .containsAnyOf("bunny", "rabbit", "кролик", "заяц", "зайч", + "flower", "цвет", "лист", "leaves", "leaf", "розов", "pink"); + } + + private io.github.ngirchev.opendaimon.common.model.Attachment loadImageAttachment() throws IOException { + org.springframework.core.io.ClassPathResource resource = + new org.springframework.core.io.ClassPathResource("attachments/objects.jpeg"); + byte[] imageBytes = resource.getInputStream().readAllBytes(); + return new io.github.ngirchev.opendaimon.common.model.Attachment( + "manual/objects.jpeg", "image/jpeg", "objects.jpeg", + imageBytes.length, + io.github.ngirchev.opendaimon.common.model.AttachmentType.IMAGE, + imageBytes); + } + // --- Helpers --- private TelegramCommand createMessageCommand(Long chatId, int messageId, String text) { @@ -499,6 +555,11 @@ private TelegramCommand createMessageCommand(Long chatId, int messageId, String } private TelegramCommand createMessageCommand(Long chatId, int messageId, String text, String languageCode) { + return createMessageCommand(chatId, messageId, text, languageCode, List.of()); + } + + private TelegramCommand createMessageCommand(Long chatId, int messageId, String text, String languageCode, + List attachments) { Update update = new Update(); User from = new User(); @@ -524,7 +585,7 @@ private TelegramCommand createMessageCommand(Long chatId, int messageId, String update, text, false, - List.of() + attachments ); command.languageCode(languageCode); return command; diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/AgentContext.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/AgentContext.java index 00b6468c..1da06864 100644 --- a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/AgentContext.java +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/AgentContext.java @@ -2,6 +2,7 @@ import io.github.ngirchev.fsm.StateContext; import io.github.ngirchev.fsm.Transition; +import io.github.ngirchev.opendaimon.common.model.Attachment; import org.jetbrains.annotations.Nullable; import java.time.Duration; @@ -40,6 +41,12 @@ public final class AgentContext implements StateContext { private final Map metadata; private final int maxIterations; private final Set enabledTools; + /** + * Multimodal attachments (e.g. images) passed alongside the task. Used by the + * Spring AI agent path to attach {@code Media} objects to the first user message + * so vision-capable models actually see the image. Defaults to {@link List#of()}. + */ + private final List attachments; private final Instant startTime; // --- Iteration tracking --- @@ -81,11 +88,18 @@ public final class AgentContext implements StateContext { public AgentContext(String task, String conversationId, Map metadata, int maxIterations, Set enabledTools) { + this(task, conversationId, metadata, maxIterations, enabledTools, List.of()); + } + + public AgentContext(String task, String conversationId, Map metadata, + int maxIterations, Set enabledTools, + List attachments) { this.task = task; this.conversationId = conversationId; this.metadata = metadata; this.maxIterations = maxIterations; this.enabledTools = enabledTools; + this.attachments = attachments == null ? List.of() : List.copyOf(attachments); this.state = AgentState.INITIALIZED; this.startTime = Instant.now(); } @@ -210,6 +224,14 @@ public Set getEnabledTools() { return Set.copyOf(enabledTools); } + /** + * Returns the multimodal attachments associated with this agent run. + * The list is unmodifiable and never {@code null}. + */ + public List getAttachments() { + return attachments; + } + // --- Iteration state accessors --- public int getCurrentIteration() { diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/AgentRequest.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/AgentRequest.java index 2862483f..0f8c4743 100644 --- a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/AgentRequest.java +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/agent/AgentRequest.java @@ -1,5 +1,8 @@ package io.github.ngirchev.opendaimon.common.agent; +import io.github.ngirchev.opendaimon.common.model.Attachment; + +import java.util.List; import java.util.Map; import java.util.Set; @@ -12,6 +15,9 @@ * @param maxIterations safety limit for ReAct loop iterations * @param enabledTools tool names to make available (empty = all discovered tools) * @param strategy execution strategy (AUTO selects based on context) + * @param attachments user-provided multimodal attachments (e.g. image attachments) to be + * carried into the first user message of the agent prompt; never null, + * defaults to {@link List#of()} when no attachments are supplied */ public record AgentRequest( String task, @@ -19,17 +25,31 @@ public record AgentRequest( Map metadata, int maxIterations, Set enabledTools, - AgentStrategy strategy + AgentStrategy strategy, + List attachments ) { private static final int DEFAULT_MAX_ITERATIONS = 10; + /** + * Compact canonical constructor — normalises {@code null} {@code attachments} + * to an empty list and defensively copies the input so the record stays immutable. + */ + public AgentRequest { + attachments = attachments == null ? List.of() : List.copyOf(attachments); + } + public AgentRequest(String task, String conversationId, Map metadata) { - this(task, conversationId, metadata, DEFAULT_MAX_ITERATIONS, Set.of(), AgentStrategy.AUTO); + this(task, conversationId, metadata, DEFAULT_MAX_ITERATIONS, Set.of(), AgentStrategy.AUTO, List.of()); } public AgentRequest(String task, String conversationId, Map metadata, int maxIterations, Set enabledTools) { - this(task, conversationId, metadata, maxIterations, enabledTools, AgentStrategy.AUTO); + this(task, conversationId, metadata, maxIterations, enabledTools, AgentStrategy.AUTO, List.of()); + } + + public AgentRequest(String task, String conversationId, Map metadata, + int maxIterations, Set enabledTools, AgentStrategy strategy) { + this(task, conversationId, metadata, maxIterations, enabledTools, strategy, List.of()); } } diff --git a/opendaimon-spring-ai/SPRING_AI_MODULE.md b/opendaimon-spring-ai/SPRING_AI_MODULE.md index bf51fdfe..c65b1e7e 100644 --- a/opendaimon-spring-ai/SPRING_AI_MODULE.md +++ b/opendaimon-spring-ai/SPRING_AI_MODULE.md @@ -359,6 +359,61 @@ tentative-answer bubble is rolled back when leaked tool markup appears. Do not t `StreamingAnswerFilter` as the sole defense against tool-call leakage into the user answer — downstream consumers that render model text to users must scan too. +### Image attachments — agent path + +When a Telegram message carrying a photo + caption (or a multimodal REST payload) +is routed to the agent path, the image bytes propagate through: + +``` +ChatAICommand.attachments() / FixedModelChatAICommand.attachments() // pipeline-processed list + └─ fallback: TelegramCommand.attachments() // only when the AI command carries no processed list + → AgentRequest.attachments() // 7-arg canonical record ctor; null → List.of() + → AgentContext.getAttachments() // populated by ReActAgentExecutor.execute/executeStream + → SpringAgentLoopActions.buildInitialUserMessage(ctx) + → UserMessage.builder().text(...).media(toImageMedia(attachments)).build() +``` + +The agent path inspects **both** AI-command shapes (mirroring `SpringAIGateway:383-387`): +`DefaultAICommandFactory` returns a `FixedModelChatAICommand` when the chat has a +preferred model fixed and a `ChatAICommand` otherwise; in both cases the pipeline +parks the processed attachment list on the AI command itself, not on +`TelegramCommand`. For an image-only PDF `AIRequestPipeline` renders each page into +an IMAGE attachment in `mutableAttachments`, and the agent must consume those +rendered pages — the raw PDF on `TelegramCommand.attachments()` would be discarded +by `toImageMedia()` as non-IMAGE. + +`toImageMedia` filters by `AttachmentType.IMAGE`, validates non-null/non-blank +mime + non-empty data, and constructs `org.springframework.ai.content.Media` from +a `ByteArrayResource` — the exact same shape `SpringDocumentPreprocessor` produces on +the gateway path, so vision-capable models receive identical multimodal prompts +regardless of which path was chosen. Document-typed attachments (PDF, DOCX, …) are +intentionally filtered out here; their RAG processing happens upstream on the +gateway path and arrives at the agent — when it arrives — as text-only context. + +**Multi-iteration invariant.** The ReAct loop reuses one `messages` list across +`think()` iterations via the `KEY_CONVERSATION_HISTORY` extras key. The first +`UserMessage(media)` is appended once when `messages.isEmpty()`; subsequent +iterations append assistant + tool messages without rebuilding from scratch, so +the original media survives every subsequent prompt rebuild. If a future refactor +reloads `messages` from a persisted store on each iteration, media must be +re-attached from `ctx.getAttachments()` — otherwise the model loses image context +after the first tool call (which was the original prod bug shape: VISION model +selected, but `Agent think: raw prompt messages` showed text only). + +**Tool-result UserMessages stay plain-text.** The follow-up `UserMessage` created +to deliver `ToolResponseMessage` content is built without media; the image is +already in the conversation context above it. + +`SimpleChainExecutor` (strategy=SIMPLE, single LLM call without tools) mirrors the +same `buildUserMessage`-with-media helper so caption-only photos in non-ReAct +flows also work. `PlanAndExecuteAgentExecutor` does **not** propagate attachments +to plan sub-tasks by default (sub-steps are textual decompositions); a TODO marks +where to revisit if a future product requirement needs an image to flow into a +specific step. + +See `docs/usecases/agent-image-attachment.md` and the use-case fixture +`TelegramAgentImageFixtureIT`. + ### Tool failure detection Spring AI's `@Tool` contract is **string-typed**: tool methods return a plain `String` diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/DefaultAgentOrchestrator.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/DefaultAgentOrchestrator.java index 1bce76da..87806a4b 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/DefaultAgentOrchestrator.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/DefaultAgentOrchestrator.java @@ -97,6 +97,9 @@ private StepResult executeStep(OrchestrationStep step, String conversationId, ? step.maxIterations() : defaultMaxIterations; + // Orchestration steps are textual plan decompositions — they do not inherit user + // image attachments (mirrors the PlanAndExecuteAgentExecutor decision). The 5-arg + // ctor resolves attachments to List.of(); see docs/usecases/agent-image-attachment.md. AgentRequest request = new AgentRequest( enrichedTask, conversationId, diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/PlanAndExecuteAgentExecutor.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/PlanAndExecuteAgentExecutor.java index 69fad481..adb48f34 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/PlanAndExecuteAgentExecutor.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/PlanAndExecuteAgentExecutor.java @@ -82,6 +82,11 @@ public AgentResult execute(AgentRequest request) { int stepMaxIterations = Math.max(3, request.maxIterations() / plan.size()); + // TODO(vision-plan): plan sub-steps currently inherit no attachments (the + // 6-arg AgentRequest overload resolves to List.of()). If a future product + // requirement needs an image to flow into a specific plan step (e.g. "compare + // regions of the attached image"), forward request.attachments() selectively + // here — out of scope for the agent-path image fix. AgentRequest stepRequest = new AgentRequest( enrichedTask, request.conversationId(), diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java index e09e2d26..532972d9 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ReActAgentExecutor.java @@ -45,7 +45,8 @@ public AgentResult execute(AgentRequest request) { request.conversationId(), request.metadata(), request.maxIterations(), - request.enabledTools() + request.enabledTools(), + request.attachments() ); agentFsm.handle(ctx, AgentEvent.START); @@ -71,7 +72,8 @@ public Flux executeStream(AgentRequest request) { request.conversationId(), request.metadata(), request.maxIterations(), - request.enabledTools() + request.enabledTools(), + request.attachments() ); // Install an event listener on the context diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SimpleChainExecutor.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SimpleChainExecutor.java index a670c4a6..2fd8ace4 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SimpleChainExecutor.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SimpleChainExecutor.java @@ -7,6 +7,8 @@ import io.github.ngirchev.opendaimon.common.agent.AgentResult; import io.github.ngirchev.opendaimon.common.agent.AgentState; import io.github.ngirchev.opendaimon.common.agent.AgentStreamEvent; +import io.github.ngirchev.opendaimon.common.model.Attachment; +import io.github.ngirchev.opendaimon.common.model.AttachmentType; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.messages.AssistantMessage; @@ -18,7 +20,10 @@ import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.util.MimeTypeUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Sinks; @@ -68,7 +73,7 @@ public AgentResult execute(AgentRequest request) { List messages = new ArrayList<>(); messages.add(new SystemMessage(SYSTEM_PROMPT)); loadConversationHistory(request, messages); - messages.add(new UserMessage(request.task())); + messages.add(buildUserMessage(request)); ChatOptions options = buildOptions(request); Prompt prompt = new Prompt(messages, options); @@ -105,7 +110,7 @@ public Flux executeStream(AgentRequest request) { List messages = new ArrayList<>(); messages.add(new SystemMessage(SYSTEM_PROMPT)); loadConversationHistory(request, messages); - messages.add(new UserMessage(request.task())); + messages.add(buildUserMessage(request)); ChatOptions options = buildOptions(request); ChatResponse response = callWithPriority(request, new Prompt(messages, options)); @@ -177,6 +182,42 @@ private ChatOptions buildOptions(AgentRequest request) { return ToolCallingChatOptions.builder().model(preferredModelId).build(); } + /** + * Builds the user message of the simple-chain prompt, attaching image + * {@link Media} when {@link AgentRequest#attachments()} contains image-typed + * entries. Mirrors {@code SpringAgentLoopActions.buildInitialUserMessage} so + * vision-capable models routed through the simple-chain path also receive + * the original image bytes (without this, captioned photos in non-ReAct + * strategies degrade to text-only prompts and the model hallucinates that + * "no image was attached"). + */ + private static UserMessage buildUserMessage(AgentRequest request) { + String text = request.task(); + List mediaList = toImageMedia(request.attachments()); + if (mediaList.isEmpty()) { + return new UserMessage(text); + } + log.debug("SimpleChain: attaching {} image media to user message", mediaList.size()); + return UserMessage.builder() + .text(text) + .media(mediaList) + .build(); + } + + private static List toImageMedia(List attachments) { + if (attachments == null || attachments.isEmpty()) { + return List.of(); + } + return attachments.stream() + .filter(a -> a.type() == AttachmentType.IMAGE) + .filter(a -> a.data() != null && a.data().length > 0) + .filter(a -> a.mimeType() != null && !a.mimeType().isBlank()) + .map(a -> new Media( + MimeTypeUtils.parseMimeType(a.mimeType()), + new ByteArrayResource(a.data()))) + .toList(); + } + private void loadConversationHistory(AgentRequest request, List messages) { if (chatMemory == null || request.conversationId() == null) { return; diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java index f39d6a48..74b736ad 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java @@ -13,6 +13,8 @@ import io.github.ngirchev.opendaimon.common.agent.AgentStepResult; import io.github.ngirchev.opendaimon.common.agent.AgentStreamEvent; import io.github.ngirchev.opendaimon.common.agent.AgentToolResult; +import io.github.ngirchev.opendaimon.common.model.Attachment; +import io.github.ngirchev.opendaimon.common.model.AttachmentType; import io.github.ngirchev.opendaimon.common.service.AIUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.memory.ChatMemory; @@ -27,12 +29,15 @@ import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.model.tool.ToolCallingManager; import org.springframework.ai.model.tool.ToolExecutionResult; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.ai.tool.metadata.ToolMetadata; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.util.MimeTypeUtils; import reactor.core.publisher.Flux; import java.net.URI; @@ -150,7 +155,7 @@ public void think(AgentContext ctx) { String systemPrompt = AgentPromptBuilder.buildSystemPrompt(ctx.getMetadata()); messages.add(new SystemMessage(systemPrompt)); loadConversationHistory(ctx, messages); - messages.add(new UserMessage(AgentPromptBuilder.buildUserMessage(ctx))); + messages.add(buildInitialUserMessage(ctx)); } List effectiveCallbacks = resolveEffectiveTools(ctx); @@ -839,4 +844,49 @@ private List getOrCreateHistory(AgentContext ctx) { } return history; } + + /** + * Builds the first {@link UserMessage} of the agent prompt, attaching image + * {@link Media} from {@link AgentContext#getAttachments()} when the user uploaded + * any image-typed attachment (caption + photo flow in Telegram, multimodal REST + * payloads, etc.). The list lives in {@code KEY_CONVERSATION_HISTORY} for the + * lifetime of one execution, so attaching media once on the first iteration is + * sufficient — subsequent iterations append assistant / tool messages but the + * original first user message (with media) stays in place for every prompt + * rebuild within {@link #think(AgentContext)}. + * + *

Document/non-image attachments are intentionally ignored here; document RAG + * processing happens upstream in the gateway path and produces text-only context. + */ + private static UserMessage buildInitialUserMessage(AgentContext ctx) { + String text = AgentPromptBuilder.buildUserMessage(ctx); + List mediaList = toImageMedia(ctx.getAttachments()); + if (mediaList.isEmpty()) { + return new UserMessage(text); + } + log.debug("Attaching {} image media to first user message in agent prompt", mediaList.size()); + return UserMessage.builder() + .text(text) + .media(mediaList) + .build(); + } + + /** + * Converts image-typed {@link Attachment}s to Spring AI {@link Media}. Mirrors the + * helper used by {@code SpringDocumentPreprocessor} / {@code SpringAIGateway} so + * the agent path produces media in the exact same shape the gateway path does. + */ + private static List toImageMedia(List attachments) { + if (attachments == null || attachments.isEmpty()) { + return List.of(); + } + return attachments.stream() + .filter(a -> a.type() == AttachmentType.IMAGE) + .filter(a -> a.data() != null && a.data().length > 0) + .filter(a -> a.mimeType() != null && !a.mimeType().isBlank()) + .map(a -> new Media( + MimeTypeUtils.parseMimeType(a.mimeType()), + new ByteArrayResource(a.data()))) + .toList(); + } } diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SimpleChainExecutorTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SimpleChainExecutorTest.java index 5948e42b..17f1a4a6 100644 --- a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SimpleChainExecutorTest.java +++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SimpleChainExecutorTest.java @@ -168,10 +168,62 @@ void shouldRouteBothExecuteAndExecuteStreamThroughPriorityRequestExecutor() thro .executeRequest(anyLong(), any(Callable.class)); } + @Test + void shouldAttachImageMediaToUserMessageWhenAttachmentsHasImage() { + // Regression guard: SimpleChainExecutor must mirror SpringAgentLoopActions and pass + // image attachments as Media on the user message. Otherwise vision-capable models + // routed through the SIMPLE strategy (e.g. caption-only photo with no tools) reach + // the LLM with text-only prompt and answer "no image was attached". + when(chatModel.call(any(org.springframework.ai.chat.prompt.Prompt.class))) + .thenReturn(chatResponse("Looks like a cat.")); + + executor.execute(requestWithImage("what is this?", "image/png", new byte[]{1, 2, 3})); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(org.springframework.ai.chat.prompt.Prompt.class); + verify(chatModel).call(captor.capture()); + org.springframework.ai.chat.messages.UserMessage userMsg = + captor.getValue().getInstructions().stream() + .filter(m -> m.getMessageType() == org.springframework.ai.chat.messages.MessageType.USER) + .map(org.springframework.ai.chat.messages.UserMessage.class::cast) + .findFirst() + .orElseThrow(); + assertThat(userMsg.getMedia()).hasSize(1); + assertThat(userMsg.getMedia().getFirst().getMimeType().toString()).isEqualTo("image/png"); + } + + @Test + void shouldFallBackToPlainUserMessageWhenAttachmentsEmpty() { + when(chatModel.call(any(org.springframework.ai.chat.prompt.Prompt.class))) + .thenReturn(chatResponse("Hi.")); + + executor.execute(request("ping")); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(org.springframework.ai.chat.prompt.Prompt.class); + verify(chatModel).call(captor.capture()); + org.springframework.ai.chat.messages.UserMessage userMsg = + captor.getValue().getInstructions().stream() + .filter(m -> m.getMessageType() == org.springframework.ai.chat.messages.MessageType.USER) + .map(org.springframework.ai.chat.messages.UserMessage.class::cast) + .findFirst() + .orElseThrow(); + assertThat(userMsg.getMedia()).isEmpty(); + } + private static AgentRequest request(String task) { return new AgentRequest(task, "conv-1", Map.of(), 5, Set.of(), AgentStrategy.SIMPLE); } + private static AgentRequest requestWithImage(String task, String mime, byte[] data) { + io.github.ngirchev.opendaimon.common.model.Attachment attachment = + new io.github.ngirchev.opendaimon.common.model.Attachment( + "photo/1", mime, "photo.png", data.length, + io.github.ngirchev.opendaimon.common.model.AttachmentType.IMAGE, data); + return new AgentRequest(task, "conv-1", Map.of(), 5, Set.of(), + AgentStrategy.SIMPLE, List.of(attachment)); + } + private static ChatResponse chatResponse(String text) { AssistantMessage msg = new AssistantMessage(text); Generation gen = new Generation(msg); diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActionsAttachmentsTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActionsAttachmentsTest.java new file mode 100644 index 00000000..212af61a --- /dev/null +++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActionsAttachmentsTest.java @@ -0,0 +1,167 @@ +package io.github.ngirchev.opendaimon.ai.springai.agent; + +import io.github.ngirchev.opendaimon.common.agent.AgentContext; +import io.github.ngirchev.opendaimon.common.model.Attachment; +import io.github.ngirchev.opendaimon.common.model.AttachmentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; +import org.springframework.ai.model.tool.ToolCallingManager; +import reactor.core.publisher.Flux; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Verifies that {@link SpringAgentLoopActions#think(AgentContext)} carries image + * attachments from {@link AgentContext#getAttachments()} into the first + * {@link UserMessage} as Spring AI {@link Media} so vision-capable models actually + * receive the picture. The agent path was previously plain-text-only — captioned + * photos arrived at the model as {@code "[USER] что тут?"} with no image_url, and + * the model would politely ask whether an image was attached. The fix mirrors what + * {@code SpringDocumentPreprocessor} does on the gateway path so both paths feed + * the LLM the same shape of multimodal prompt. + */ +class SpringAgentLoopActionsAttachmentsTest { + + private static final byte[] PNG_BYTES = new byte[]{(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10}; + + private ChatModel chatModel; + private SpringAgentLoopActions actions; + + @BeforeEach + void setUp() { + chatModel = mock(ChatModel.class); + ToolCallingManager toolCallingManager = mock(ToolCallingManager.class); + actions = new SpringAgentLoopActions( + chatModel, toolCallingManager, List.of(), null, Duration.ofSeconds(30)); + } + + @Test + void shouldAttachImageMediaToFirstUserMessageWhenAttachmentsPresent() { + AgentContext ctx = contextWithAttachments(List.of( + imageAttachment("photo/1.png", "image/png", PNG_BYTES))); + stubFinalAnswerStream("ok"); + + actions.think(ctx); + + UserMessage firstUserMessage = firstUserMessageInPrompt(); + assertThat(firstUserMessage.getMedia()) + .as("First user message must carry the image as Media — otherwise the vision model gets text only") + .hasSize(1); + Media media = firstUserMessage.getMedia().getFirst(); + assertThat(media.getMimeType().toString()).isEqualTo("image/png"); + assertThat(firstUserMessage.getText()) + .as("Original task text must still be present alongside media") + .contains("test task"); + } + + @Test + void shouldUsePlainUserMessageWhenAttachmentsAreEmpty() { + AgentContext ctx = contextWithAttachments(List.of()); + stubFinalAnswerStream("ok"); + + actions.think(ctx); + + UserMessage firstUserMessage = firstUserMessageInPrompt(); + assertThat(firstUserMessage.getMedia()) + .as("Without attachments the prompt must remain plain-text — adding empty media() may confuse providers") + .isEmpty(); + } + + @Test + void shouldFilterOutNonImageAttachments() { + AgentContext ctx = contextWithAttachments(List.of( + imageAttachment("doc.pdf", "application/pdf", new byte[]{1, 2, 3}, AttachmentType.PDF), + imageAttachment("photo.jpg", "image/jpeg", PNG_BYTES, AttachmentType.IMAGE))); + stubFinalAnswerStream("ok"); + + actions.think(ctx); + + UserMessage firstUserMessage = firstUserMessageInPrompt(); + assertThat(firstUserMessage.getMedia()) + .as("Only IMAGE-typed attachments belong in the multimodal prompt — PDFs go through the gateway RAG path") + .hasSize(1); + assertThat(firstUserMessage.getMedia().getFirst().getMimeType().toString()).isEqualTo("image/jpeg"); + } + + @Test + void shouldRetainImageMediaAcrossSubsequentThinkIterations() { + // Regression guard for the ReAct multi-iteration model: messages list lives in + // KEY_CONVERSATION_HISTORY and is mutated across iterations (assistant + tool messages + // are appended). The first UserMessage with media must remain in place — if some future + // refactor rebuilds messages from scratch each iteration without re-attaching media, + // the second think() call would reach the LLM with text-only prompt and reproduce + // the original bug after the first tool call. + AgentContext ctx = contextWithAttachments(List.of( + imageAttachment("photo.png", "image/png", PNG_BYTES))); + stubFinalAnswerStream("ok"); + + actions.think(ctx); + actions.think(ctx); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Prompt.class); + verify(chatModel, org.mockito.Mockito.atLeast(2)).stream(captor.capture()); + + for (Prompt prompt : captor.getAllValues()) { + UserMessage first = prompt.getInstructions().stream() + .filter(m -> m.getMessageType() == MessageType.USER) + .map(UserMessage.class::cast) + .findFirst() + .orElseThrow(() -> new AssertionError("No UserMessage in prompt")); + assertThat(first.getMedia()) + .as("Every think() iteration must rebuild a Prompt that still carries the image media on the first user message") + .hasSize(1); + } + } + + // ── helpers ────────────────────────────────────────────────────────── + + private AgentContext contextWithAttachments(List attachments) { + return new AgentContext("test task", "conv-1", Map.of(), 5, Set.of(), attachments); + } + + private static Attachment imageAttachment(String key, String mime, byte[] data) { + return imageAttachment(key, mime, data, AttachmentType.IMAGE); + } + + private static Attachment imageAttachment(String key, String mime, byte[] data, AttachmentType type) { + return new Attachment(key, mime, key, data.length, type, data); + } + + private void stubFinalAnswerStream(String text) { + ChatResponse chunk = new ChatResponse(List.of(new Generation(new AssistantMessage(text)))); + when(chatModel.stream(any(Prompt.class))).thenReturn(Flux.just(chunk)); + } + + private UserMessage firstUserMessageInPrompt() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Prompt.class); + verify(chatModel).stream(captor.capture()); + Prompt prompt = captor.getValue(); + return prompt.getInstructions().stream() + .filter(m -> m.getMessageType() == MessageType.USER) + .map(UserMessage.class::cast) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Prompt has no UserMessage; messages were: " + prompt.getInstructions().stream() + .map(Message::getMessageType) + .toList())); + } +} diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index ea67cf42..bdca7291 100644 --- a/opendaimon-telegram/TELEGRAM_MODULE.md +++ b/opendaimon-telegram/TELEGRAM_MODULE.md @@ -238,6 +238,19 @@ Implementation: `TelegramMessageHandlerActions` orchestrates the two-message sta --- +### UC-3A: Photo attachment in agent mode (REACT, thinking enabled) +**Trigger:** user sends a photo while the chat is in agent mode (`open-daimon.agent.enabled=true`, agent mode toggled on for the chat) +**Mapping:** identical to UC-3 (`mapToTelegramPhotoCommand` → `Attachment(type=IMAGE)`) +**Command:** `MESSAGE`, `attachments=[Attachment]`, `userText` = caption (e.g. «что тут?») +**Handler:** `TelegramMessageHandlerActions.generateResponse` — agent path +4. Factory → `ChatAICommand(capabilities={CHAT, VISION})`; `DefaultAICommandFactory` resolves `requiredCaps=[AUTO, VISION]` +5. `TelegramMessageHandlerActions` builds `AgentRequest(..., attachments=...)` and routes to `AgentExecutor.executeStream(...)`. The attachment source is the pipeline-processed list on the AI command — `ChatAICommand.attachments()` for the default path, `FixedModelChatAICommand.attachments()` when the chat has a preferred model fixed (mirrors `SpringAIGateway:383-387`). `TelegramCommand.attachments()` is used only as a fallback when the AI command carries no processed list, so image-only PDFs that `AIRequestPipeline` rendered page-by-page into IMAGE attachments are not silently dropped. +6. `ReActAgentExecutor` carries attachments into `AgentContext`; `SpringAgentLoopActions.think()` builds the first `UserMessage` with `Media` (see `SPRING_AI_MODULE.md#image-attachments--agent-path`) +**Output:** vision-capable model describes the image, agent loop terminates on the first `FINAL_ANSWER` (no tool call needed for a pure description) +**Regression guarded by:** `TelegramAgentImageFixtureIT`, `SpringAgentLoopActionsAttachmentsTest`, `TelegramMessageHandlerActionsAgentTest#shouldPassAttachmentsToAgentRequestWhenCommandHasImage` + +--- + ### UC-4: Photo, fixed model that supports VISION **Trigger:** photo + user has preferred model with VISION capability 4. Factory → `FixedModelChatAICommand(capabilities={CHAT, VISION}, fixedModelId=...)` diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java index 072ac924..66e5cb68 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java @@ -7,6 +7,8 @@ import io.github.ngirchev.opendaimon.common.agent.AgentRequest; import io.github.ngirchev.opendaimon.common.ai.AIGateways; import io.github.ngirchev.opendaimon.common.ai.command.AICommand; +import io.github.ngirchev.opendaimon.common.ai.command.ChatAICommand; +import io.github.ngirchev.opendaimon.common.ai.command.FixedModelChatAICommand; import io.github.ngirchev.opendaimon.common.ai.pipeline.AIRequestPipeline; import io.github.ngirchev.opendaimon.common.ai.response.AIResponse; import io.github.ngirchev.opendaimon.common.ai.response.SpringAIStreamResponse; @@ -360,13 +362,37 @@ private void generateAgentResponse(MessageHandlerContext ctx) { ? aiCommand.userRole() : command.userText(); + // Forward image attachments into the agent path so the first user message + // in the agent prompt carries Media — without this, vision-capable models + // are selected (capabilities=[CHAT, VISION]) but receive only the caption + // text and answer "are there any images?" (see SPRING_AI_MODULE.md, agent + // path media propagation). Source must be aiCommand.attachments() (the + // pipeline-processed list, mirroring SpringAIGateway:383-387), not the raw + // command.attachments(): for an image-only PDF the pipeline rendered each + // page into an IMAGE attachment in mutableAttachments, and the agent path + // must see those rendered pages — not the original PDF bytes that + // toImageMedia() then discards as non-IMAGE. Both AI-command shapes carry + // the pipeline-processed list — DefaultAICommandFactory returns + // FixedModelChatAICommand whenever a preferred model is fixed, otherwise + // ChatAICommand — so we must inspect both before falling back to raw. + List agentAttachments; + if (aiCommand instanceof ChatAICommand chat && chat.attachments() != null) { + agentAttachments = chat.attachments(); + } else if (aiCommand instanceof FixedModelChatAICommand fixed && fixed.attachments() != null) { + agentAttachments = fixed.attachments(); + } else if (command.attachments() != null) { + agentAttachments = command.attachments(); + } else { + agentAttachments = List.of(); + } AgentRequest request = new AgentRequest( agentTask, metadata.get(THREAD_KEY_FIELD), metadata, agentMaxIterations, Set.of(), - strategy + strategy, + agentAttachments ); // Stream agent events — two-message UX: diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java index c10b7401..f476ddd2 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java @@ -7,6 +7,8 @@ import io.github.ngirchev.opendaimon.common.agent.AgentStreamEvent; import io.github.ngirchev.opendaimon.common.ai.ModelCapabilities; import io.github.ngirchev.opendaimon.common.ai.command.AICommand; +import io.github.ngirchev.opendaimon.common.ai.command.ChatAICommand; +import io.github.ngirchev.opendaimon.common.ai.command.FixedModelChatAICommand; import io.github.ngirchev.opendaimon.common.ai.pipeline.AIRequestPipeline; import io.github.ngirchev.opendaimon.common.service.AIGateway; import io.github.ngirchev.opendaimon.common.service.AIGatewayRegistry; @@ -240,6 +242,238 @@ void generateResponse_autoCapability_usesAutoStrategy() { assertThat(ctx.getResponseText()).hasValue("Result"); } + @Test + @DisplayName("generateResponse forwards image attachments from TelegramCommand into AgentRequest") + void shouldPassAttachmentsToAgentRequestWhenCommandHasImage() { + // Regression guard for the prod bug (2026-04-25 logs, chatId=-5267226692): + // photo + caption "что тут?" reached DefaultAICommandFactory with attachments=1, + // routing resolved a vision-capable model, but AgentRequest had no attachments + // field — the image was dropped before the prompt was built. Without this test + // the wiring can silently regress next time someone refactors generateResponse. + TelegramCommand command = mock(TelegramCommand.class); + when(command.userText()).thenReturn("что тут?"); + when(command.telegramId()).thenReturn(-5267226692L); + io.github.ngirchev.opendaimon.common.model.Attachment image = + new io.github.ngirchev.opendaimon.common.model.Attachment( + "photo/abc", "image/jpeg", "photo.jpg", 1024L, + io.github.ngirchev.opendaimon.common.model.AttachmentType.IMAGE, + new byte[]{1, 2, 3}); + when(command.attachments()).thenReturn(List.of(image)); + + Map metadata = new HashMap<>(); + metadata.put(AICommand.THREAD_KEY_FIELD, "test-thread-key"); + metadata.put(AICommand.USER_ID_FIELD, "42"); + MessageHandlerContext ctx = new MessageHandlerContext(command, null, s -> {}); + ctx.setMetadata(metadata); + ctx.setModelCapabilities(Set.of(ModelCapabilities.AUTO)); + + Flux stream = Flux.just(AgentStreamEvent.finalAnswer("Looks like a cat", 1)); + ArgumentCaptor captor = ArgumentCaptor.forClass(AgentRequest.class); + when(agentExecutor.executeStream(captor.capture())).thenReturn(stream); + + actions.generateResponse(ctx); + + AgentRequest request = captor.getValue(); + assertThat(request.attachments()) + .as("Image attachments from TelegramCommand must be carried into AgentRequest " + + "so SpringAgentLoopActions can attach Media to the first user message") + .hasSize(1) + .first() + .satisfies(a -> { + assertThat(a.type()).isEqualTo( + io.github.ngirchev.opendaimon.common.model.AttachmentType.IMAGE); + assertThat(a.mimeType()).isEqualTo("image/jpeg"); + }); + } + + @Test + @DisplayName("generateResponse prefers aiCommand processed attachments over raw command attachments") + void shouldPreferAiCommandAttachmentsOverRawCommandAttachmentsWhenAiCommandIsChatAICommand() { + // Regression guard for image-only PDFs in agent mode: AIRequestPipeline renders + // each PDF page into an IMAGE attachment in mutableAttachments, and the result + // lands in ChatAICommand.attachments() — not in TelegramCommand.attachments(), + // which still holds the raw PDF bytes. The agent path must read the pipeline- + // processed list (mirroring SpringAIGateway.java:384), otherwise the rendered + // pages are lost and toImageMedia() drops the raw PDF as non-IMAGE. + TelegramCommand command = mock(TelegramCommand.class); + // Intentionally no command.userText() / telegramId() / attachments() stubs: + // when ChatAICommand carries the processed payload, the agent path uses + // aiCommand.userRole() and aiCommand.attachments() exclusively — Mockito's + // strict mode flags the raw-command stubs as unnecessary if we add them. + io.github.ngirchev.opendaimon.common.model.Attachment renderedPage = + new io.github.ngirchev.opendaimon.common.model.Attachment( + "doc/scan-page-1", "image/png", "scan-page-1.png", 2048L, + io.github.ngirchev.opendaimon.common.model.AttachmentType.IMAGE, + new byte[]{9, 9, 9}); + ChatAICommand processedAiCommand = new ChatAICommand( + Set.of(ModelCapabilities.CHAT, ModelCapabilities.VISION), + 0.7, 1024, "system", "опиши документ", + Map.of(AICommand.THREAD_KEY_FIELD, "test-thread-key")); + // Build a fresh ChatAICommand carrying the rendered page in attachments + // (the no-attachments ctor sets it to List.of(); use the canonical 11-arg + // ctor instead so we can pin a specific image attachment). + processedAiCommand = new ChatAICommand( + Set.of(ModelCapabilities.CHAT, ModelCapabilities.VISION), + Set.of(), + 0.7, 1024, null, "system", "опиши документ", false, + new HashMap<>(Map.of(AICommand.THREAD_KEY_FIELD, "test-thread-key", + AICommand.USER_ID_FIELD, "42")), + new HashMap<>(), + List.of(renderedPage)); + + Map metadata = new HashMap<>(); + metadata.put(AICommand.THREAD_KEY_FIELD, "test-thread-key"); + metadata.put(AICommand.USER_ID_FIELD, "42"); + MessageHandlerContext ctx = new MessageHandlerContext(command, null, s -> {}); + ctx.setMetadata(metadata); + ctx.setAiCommand(processedAiCommand); + ctx.setModelCapabilities(processedAiCommand.modelCapabilities()); + + Flux stream = Flux.just( + AgentStreamEvent.finalAnswer("Документ описан", 1)); + ArgumentCaptor captor = ArgumentCaptor.forClass(AgentRequest.class); + when(agentExecutor.executeStream(captor.capture())).thenReturn(stream); + + actions.generateResponse(ctx); + + AgentRequest request = captor.getValue(); + assertThat(request.attachments()) + .as("agent path must use the pipeline-processed image pages, not the raw PDF") + .hasSize(1) + .first() + .satisfies(a -> { + assertThat(a.type()).isEqualTo( + io.github.ngirchev.opendaimon.common.model.AttachmentType.IMAGE); + assertThat(a.mimeType()).isEqualTo("image/png"); + assertThat(a.filename()).isEqualTo("scan-page-1.png"); + }); + } + + @Test + @DisplayName("generateResponse prefers FixedModelChatAICommand processed attachments over raw command attachments") + void shouldPreferAiCommandAttachmentsOverRawCommandAttachmentsWhenAiCommandIsFixedModelChatAICommand() { + // Regression guard mirroring the ChatAICommand case for the fixed-model branch: + // when a user pinned a preferred model, DefaultAICommandFactory returns a + // FixedModelChatAICommand instead of a ChatAICommand. AIRequestPipeline still + // renders an image-only PDF page-by-page into IMAGE attachments and parks the + // result on the AI command — but on FixedModelChatAICommand.attachments(), not + // on TelegramCommand.attachments(). The agent path must inspect this branch + // (mirroring SpringAIGateway:383-387), otherwise fixed-model agent runs drop + // the rendered pages and pass the original PDF that toImageMedia() discards. + TelegramCommand command = mock(TelegramCommand.class); + // Intentionally no command.userText() / telegramId() / attachments() stubs: + // when the AI command carries the processed payload, the agent path uses + // aiCommand.userRole() and aiCommand.attachments() exclusively. + io.github.ngirchev.opendaimon.common.model.Attachment renderedPage = + new io.github.ngirchev.opendaimon.common.model.Attachment( + "doc/scan-page-1", "image/png", "scan-page-1.png", 2048L, + io.github.ngirchev.opendaimon.common.model.AttachmentType.IMAGE, + new byte[]{9, 9, 9}); + FixedModelChatAICommand processedAiCommand = new FixedModelChatAICommand( + "openrouter/google/gemini-2.5-flash-preview", + Set.of(ModelCapabilities.CHAT, ModelCapabilities.VISION), + 0.7, 1024, null, "system", "опиши документ", false, + new HashMap<>(Map.of(AICommand.THREAD_KEY_FIELD, "test-thread-key", + AICommand.USER_ID_FIELD, "42")), + new HashMap<>(), + List.of(renderedPage)); + + Map metadata = new HashMap<>(); + metadata.put(AICommand.THREAD_KEY_FIELD, "test-thread-key"); + metadata.put(AICommand.USER_ID_FIELD, "42"); + MessageHandlerContext ctx = new MessageHandlerContext(command, null, s -> {}); + ctx.setMetadata(metadata); + ctx.setAiCommand(processedAiCommand); + ctx.setModelCapabilities(processedAiCommand.modelCapabilities()); + + Flux stream = Flux.just( + AgentStreamEvent.finalAnswer("Документ описан", 1)); + ArgumentCaptor captor = ArgumentCaptor.forClass(AgentRequest.class); + when(agentExecutor.executeStream(captor.capture())).thenReturn(stream); + + actions.generateResponse(ctx); + + AgentRequest request = captor.getValue(); + assertThat(request.attachments()) + .as("agent path must use the pipeline-processed image pages from " + + "FixedModelChatAICommand, not the raw PDF on TelegramCommand") + .hasSize(1) + .first() + .satisfies(a -> { + assertThat(a.type()).isEqualTo( + io.github.ngirchev.opendaimon.common.model.AttachmentType.IMAGE); + assertThat(a.mimeType()).isEqualTo("image/png"); + assertThat(a.filename()).isEqualTo("scan-page-1.png"); + }); + } + + @Test + @DisplayName("generateResponse uses aiCommand.userRole (RAG-augmented) as agent task, not raw command.userText") + void shouldPassAugmentedUserRoleAsAgentTaskWhenChatAICommandHasRagAugmentedQuery() { + // Regression guard for textual PDF / DOCX in agent mode: AIRequestPipeline runs RAG + // (extract text → chunk → embedding → similarity search → augment) BEFORE the + // agent-vs-gateway branching, and parks the augmented query on + // ChatAICommand.userRole(). The agent path must read userRole() and not the raw + // TelegramCommand.userText(), otherwise the document content silently disappears + // before the prompt and the model answers from the bare caption only. + TelegramCommand command = mock(TelegramCommand.class); + // No command.userText() / attachments() stubs — the ChatAICommand path must not + // touch them when userRole is set; Mockito strict mode would flag any unused stub. + + String rawCaption = "сколько было упомянуто в документе компаний?"; + String augmentedQuery = "Context:\nThe report mentions five companies: Acme, Globex, Initech, " + + "Umbrella and Soylent.\n\nQuestion: " + rawCaption; + + ChatAICommand processedAiCommand = new ChatAICommand( + Set.of(ModelCapabilities.CHAT), + Set.of(), + 0.7, 1024, null, "system", augmentedQuery, false, + new HashMap<>(Map.of(AICommand.THREAD_KEY_FIELD, "test-thread-key", + AICommand.USER_ID_FIELD, "42")), + new HashMap<>(), + List.of()); + + Map metadata = new HashMap<>(); + metadata.put(AICommand.THREAD_KEY_FIELD, "test-thread-key"); + metadata.put(AICommand.USER_ID_FIELD, "42"); + MessageHandlerContext ctx = new MessageHandlerContext(command, null, s -> {}); + ctx.setMetadata(metadata); + ctx.setAiCommand(processedAiCommand); + ctx.setModelCapabilities(processedAiCommand.modelCapabilities()); + + Flux stream = Flux.just( + AgentStreamEvent.finalAnswer("Пять компаний", 1)); + ArgumentCaptor captor = ArgumentCaptor.forClass(AgentRequest.class); + when(agentExecutor.executeStream(captor.capture())).thenReturn(stream); + + actions.generateResponse(ctx); + + AgentRequest request = captor.getValue(); + assertThat(request.task()) + .as("agent task must be the pipeline-augmented query carrying RAG context, " + + "not the bare caption — otherwise document content is lost before the prompt") + .isEqualTo(augmentedQuery) + .contains("five companies") + .contains(rawCaption); + } + + @Test + @DisplayName("generateResponse passes empty attachments when TelegramCommand has none") + void shouldPassEmptyAttachmentsToAgentRequestWhenCommandHasNoAttachments() { + // Negative guard — text-only commands must not crash on null attachments() and + // must produce a non-null empty list, mirroring the AgentRequest compact-ctor + // contract (canonical-ctor normalises null → List.of()). + MessageHandlerContext ctx = createContextWithMetadata("hello"); + + Flux stream = Flux.just(AgentStreamEvent.finalAnswer("hi", 1)); + ArgumentCaptor captor = ArgumentCaptor.forClass(AgentRequest.class); + when(agentExecutor.executeStream(captor.capture())).thenReturn(stream); + + actions.generateResponse(ctx); + + assertThat(captor.getValue().attachments()).isNotNull().isEmpty(); + } + @Test @DisplayName("generateResponse uses SIMPLE for REGULAR with only CHAT capability") void generateResponse_chatOnlyCapability_usesSimpleStrategy() { From f2e805e882e4a5b931880297161620057fe58ade Mon Sep 17 00:00:00 2001 From: Nikolay Girchev Date: Tue, 28 Apr 2026 08:21:09 +0300 Subject: [PATCH 14/14] Stream By Codex (#28) * Stream By Codex * review * Review * Fix tests * feature/td-1-stream-view-state-isolation (#30) * Fix thinking * Fix thinking * Fix thinking v2 * Fix thinking v3 * Fix thinking v4 * Fix thinking v4 --- docs/review/experiment2.md | 256 +++++++++ docs/review/experiment2_claude.md | 17 + docs/review/experiment2_codex.md | 150 ++++++ docs/team/td-1-stream-view-state-isolation.md | 369 +++++++++++++ docs/team/td-2-4-5-handler-actions-cleanup.md | 222 ++++++++ docs/team/td-future-singleton-hardening.md | 183 +++++++ ...legramMessageHandlerActionsTestWiring.java | 68 +++ .../fixture/config/TelegramFixtureConfig.java | 24 +- .../it/telegram/TelegramMockGatewayIT.java | 25 +- .../MessageTelegramCommandHandlerIT.java | 24 +- .../application-integration-test.yaml | 5 + .../application-manual-openrouter.yaml | 6 + .../src/main/resources/application.yml | 10 +- .../src/test/resources/application-test.yml | 5 + opendaimon-spring-ai/SPRING_AI_MODULE.md | 5 + .../agent/ToolObservationClassifier.java | 13 + .../agent/ToolObservationClassifierTest.java | 14 + opendaimon-telegram/TELEGRAM_MODULE.md | 499 +++++------------- .../impl/fsm/MessageHandlerContext.java | 9 + .../impl/fsm/MessageHandlerErrorType.java | 3 + .../fsm/TelegramDeliveryFailedException.java | 12 + .../fsm/TelegramMessageHandlerActions.java | 420 ++------------- .../impl/fsm/TelegramMessageSender.java | 127 +++++ .../telegram/config/TelegramAutoConfig.java | 1 + .../config/TelegramCommandHandlerConfig.java | 35 +- .../telegram/config/TelegramProperties.java | 57 +- .../InMemoryModelSelectionSession.java | 13 +- .../service/PersistentKeyboardService.java | 20 +- .../service/TelegramAgentStreamModel.java | 342 ++++++++++++ .../service/TelegramAgentStreamView.java | 219 ++++++++ .../telegram/service/TelegramChatPacer.java | 16 + .../service/TelegramChatPacerImpl.java | 67 +++ .../MessageTelegramCommandHandlerTest.java | 13 +- .../impl/fsm/MessageHandlerContextTest.java | 47 ++ ...elegramMessageHandlerActionsAgentTest.java | 20 +- ...ramMessageHandlerActionsStreamingTest.java | 64 ++- ...essageHandlerActionsTentativeEditTest.java | 213 -------- .../InMemoryModelSelectionSessionTest.java | 39 ++ .../PersistentKeyboardServiceTest.java | 43 +- .../service/TelegramAgentStreamModelTest.java | 280 ++++++++++ ...elegramAgentStreamViewConcurrencyTest.java | 243 +++++++++ .../service/TelegramAgentStreamViewTest.java | 109 ++++ 42 files changed, 3244 insertions(+), 1063 deletions(-) create mode 100644 docs/review/experiment2.md create mode 100644 docs/review/experiment2_claude.md create mode 100644 docs/review/experiment2_codex.md create mode 100644 docs/team/td-1-stream-view-state-isolation.md create mode 100644 docs/team/td-2-4-5-handler-actions-cleanup.md create mode 100644 docs/team/td-future-singleton-hardening.md create mode 100644 opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/TelegramMessageHandlerActionsTestWiring.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramDeliveryFailedException.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModel.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamView.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatPacer.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatPacerImpl.java create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerContextTest.java delete mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModelTest.java create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamViewConcurrencyTest.java create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamViewTest.java diff --git a/docs/review/experiment2.md b/docs/review/experiment2.md new file mode 100644 index 00000000..7318e004 --- /dev/null +++ b/docs/review/experiment2.md @@ -0,0 +1,256 @@ +# Experiment 2 — Сравнение моделей: 5-3 vs 5-5 + +## TL;DR + +- **5-3** имеет реальную интеграцию: `TelegramMessageHandlerActions` переписан, + новые beans (`TelegramAgentStreamModel`, `View`, `ChatPacer`, reliable `Sender`) + подключены к pipeline. Юзер видит per-chat pacing + retry-after auto-recovery. + Ветка содержит CRITICAL race в singleton view и около 600 строк мёртвого кода — + оба дефекта исправимы в существующих файлах. +- **5-5** имеет более чистый design (`AssistantTurn` как domain-объект, + `TelegramRateLimitedBot` как блокирующий фасад by construction), **но не + интегрирован**: `git diff fsm-5..fsm-5-5` показывает 0 строк в `command/`, + в auto-config'ах и в `application*.yml`. Production-flow от Telegram update + до ответа юзеру в 5-5 идентичен `fsm-5`. Вклад в решение задачи 429 — нулевой. +- **Для merge брать 5-3.** Идеи 5-5 (`AssistantTurn`, blocking facade, virtual-clock + тесты) подбирать в следующую итерацию архитектуры как отдельную работу. + +Сравнение двух архитектурных подходов к решению одной задачи: устранения ошибок +HTTP 429 (Too Many Requests) при отправке сообщений в Telegram во время агентского +streaming-цикла, плюс корректное отображение статуса/частичного ответа/финального +ответа в чате. + +Базовая ветка обеих моделей — `fsm-5` (`b0dc300`, fsm-5-2-attachment-fix #26). +Изначальный фикс в `fsm-5` оперирует только локальным дебаунсом edit-вызовов +(`TelegramProgressBatcher`) и graceful-cut-ротацией длинного буфера +(`TelegramBufferRotator`). Глобальной квоты Telegram и retry-after-логики в нём нет. + +## Хронология (25 апреля 2026) + +``` +17:30 fsm-5 (b0dc300) base +17:57 fsm-5-3-telegram-outbound-queue checkout от fsm-5 [5-3 v1] +18:44 d073eff "Rate Limiter Codex" + TelegramOutboundDispatcher + Impl + sender + tests +18:55 fsm-5-4-stream-aggregation checkout от 5-3-v1 +20:29 stash @fsm-5-4: "outbound queue + tests — saved before fresh start from fsm-5" +21:46 fsm-5-5-assistant-turn-view (fd271bc) "Rate Limiter Codex" [5-5] + AssistantTurn + TelegramAssistantTurnView + TelegramRateLimitedBot +22:08 fsm-5-3-telegram-stream-view checkout от fsm-5 [5-3 v2] +23:02 6cf4af5 "Stream By Codex" + TelegramAgentStreamModel + View + ChatPacer + reliable MessageSender +23:40 fsm-5-3-stream-view: review Claude нашёл CRITICAL race +23:54 fsm-5-5: experiment2_claude.md Codex нашёл P1 + 2× P2 +``` + +5-3 и 5-5 написаны в один день в течение ~6 часов. 5-3 имеет две итерации +(outbound-queue → stream-view), которые здесь рассматриваются как одна школа +мысли. 5-5 — одна попытка, написанная между этими двумя итерациями 5-3. + +## Школа мысли 5-3: разделение по слоям + +Идея: «отделить транспорт (как именно отправить в Telegram, соблюдая лимиты) от +логики (что именно отправить)». Реализуется через несколько одноответственных +классов поверх `TelegramBot`. + +### Версия 1 — `fsm-5-3-telegram-outbound-queue` + +| Компонент | Роль | +|---|---| +| `TelegramOutboundDispatcher.submit(Operation) -> CompletableFuture` | async очередь отправки, per-chat | +| `Operation` с `coalescingKey`, `deadlineMs`, `retryOnRateLimit` | замена непрожитых edit'ов, deadline'ы, авто-retry | +| `TelegramOutboundDispatcherImpl` (323 строки) | per-chat queue + sliding-window глобальной квоты + drain через `ScheduledExecutorService` | +| `TelegramMessageSender` | пользовательский API, прячущий dispatcher | +| `TelegramDeliveryFailedException` | сигнализация наверх о фейле доставки | + +Защита 429: реактивная — dispatcher держит окно, при 429 ретраится по retry_after, +deadline защищает от вечного ожидания, coalescing склеивает накопившиеся edit'ы. + +После работы автор зафиксировал stash «saved before fresh start from fsm-5» и +переключился на параллельный эксперимент 5-5. + +### Версия 2 — `fsm-5-3-telegram-stream-view` + +| Компонент | Роль | +|---|---| +| `TelegramAgentStreamModel` (292 стр.) | provider-neutral state: status / candidate answer / confirmed answer | +| `TelegramAgentStreamView` (185 стр.) | рендер снапшотов модели в Telegram-сообщения | +| `TelegramChatPacer` (`tryReserve` / `reserve(timeoutMs)`) | per-chat pacing gate, 1с private / 3с group | +| `TelegramMessageSender.sendHtmlReliable... / editHtmlReliable` | парсинг retry_after из 429, до 2 попыток | + +Защита 429: пассивная per-chat — pacer не пускает чаще `intervalMs(chatId)`; +reliable-методы парсят retry_after и повторяют. Глобальной квоты нет. + +### Сильные стороны школы 5-3 + +- Чёткое разделение по слоям (Model / View / Pacer / Sender). Можно подменять каждый. +- `TelegramAgentStreamModel` (v2) провайдер-нейтральная — теоретически можно рендерить в Discord/Slack. +- Stateless утилиты (`TelegramProgressBatcher`, `TelegramBufferRotator`) хорошо изолированы. +- Coalescing edit'ов в v1 содержит правильную идею: если edit ещё в очереди, его можно заменить свежим снапшотом без двух round-trip'ов. + +### Слабые стороны школы 5-3 + +- v1 переусложнена (futures, executor, coalescing keys) для случая, где достаточно sync-фасада. +- v2 оставляет около 600 строк мёртвого кода в `TelegramMessageHandlerActions` (старое дерево `handleAgentStreamEvent`, `handlePartialAnswer`, `promoteTentativeAnswer`, `editTentativeAnswer`, `rollbackAndAppendToolCall`, `forceFinalAnswerEdit` и связанные). Тесты `TelegramMessageHandlerActionsTentativeEditTest` дёргают это через reflection и зеленеют, создавая ложную уверенность в покрытии. +- v2 содержит CRITICAL race: `TelegramAgentStreamView.statusRenderedOffset` — обычное `int` поле без `volatile` / `synchronized` в singleton-bean (`@Bean` в `TelegramCommandHandlerConfig:241`). Два параллельных чата перезапишут offset друг другу — в Telegram уйдёт неправильный срез HTML или произойдёт `IndexOutOfBoundsException` на ротации. +- Глобальной квоты Telegram (≈30 msg/s на бот) нет в v2. В v1 есть, но v2 от неё отказался. +- `MessageHandlerErrorType.TELEGRAM_DELIVERY_FAILED` устанавливается, но не маппится ни в FSM-переход, ни в локализованное сообщение — наружу ведёт себя как GENERAL. +- `agent-stream-edit-min-interval-ms` в `TelegramProperties` стал misleading: единственные consumer'ы живут в dead-коде. +- `PersistentKeyboardService.sendKeyboard` стал blocking (до ~4с в группе) без отметки в javadoc. +- `TelegramAgentStreamModel` создаёт `new ObjectMapper()` per-request, хотя в Spring уже есть готовый bean. +- Сам факт двух итераций без слияния — индикатор, что архитектура не устоялась. + +## Школа мысли 5-5: domain-объект + блокирующий фасад + +Идея: «тот, кто шлёт в Telegram, не должен знать про rate limit; bot-фасад блокирует +caller'а до доступного слота, поэтому 429 не может произойти by construction». + +### Решающий факт: 5-5 не интегрирован в production pipeline + +Это главное, что нужно понимать про 5-5 при оценке вклада. Все три новых класса +существуют только как изолированные файлы. Проверка по `git diff fsm-5..fsm-5-5`: + +| Что должно быть изменено для интеграции | Что фактически изменено | +|---|---| +| `TelegramMessageHandlerActions.java` (центр pipeline) | 0 строк | +| `TelegramAutoConfig.java` / `TelegramCommandHandlerConfig.java` / `TelegramServiceConfig.java` | 0 строк | +| `application.yml` / `application-test.yml` / `application-integration-test.yaml` | 0 строк | +| `TelegramProperties.java` | +35 строк (новый nested класс `RateLimit`) | +| Новые классы (`AssistantTurn`, `TelegramAssistantTurnView`, `TelegramRateLimitedBot`) | +628 строк | +| Тесты | +751 строка | + +`TelegramRateLimitedBot` ни в одном `@Bean` не создаётся, `AssistantTurn` нигде не +инстанцируется в горячем пути, `TelegramAssistantTurnView` не подключён к +`onChange` ни одного реального `AssistantTurn`. Production-flow от Telegram update +до ответа юзеру в 5-5 идентичен `fsm-5`: всё ещё работают `handleAgentStreamEvent`, +tentative-bubble логика, старый `TelegramMessageSender` без rate-limit. + +Юзер, отправивший сообщение боту на 5-5, увидит ровно то же поведение и получит +429 при тех же условиях, что на базе `fsm-5`. Фактический вклад 5-5 в решение +исходной задачи — **нулевой**. + +Маркер от автора: в `TODO.md` на ветке 5-5 явная заметка +*«Out of scope. Telegram outbound-queue refactor (`fsm-5-5-assistant-turn-view`) +— orthogonal, do not mix the two in one PR»* — то есть автор сам классифицирует +эту ветку как незавершённый refactor, а не как готовый PR. + +Регрессионный риск ненулевой: `TelegramProperties.RateLimit` помечен `@Validated` +с `@Min/@Max`, поэтому если оператор пропишет в `application.yml` +`globalPerSecond: 99`, приложение упадёт на старте — несмотря на то, что лимит +никем не используется в runtime. + +### Компоненты (как написаны, не как подключены) + +| Компонент | Роль | +|---|---| +| `AssistantTurn` (139 стр.) | domain-объект «один ход агента», single-writer, lifecycle `STREAMING → SETTLED / ERROR`, `setOnChange` callback для подписки view | +| `TelegramAssistantTurnView` (251 стр.) | реконсилит `AssistantTurn` в status bubble + answer bubble[s] на каждый `onChange` | +| `TelegramRateLimitedBot` (238 стр.) | синхронный блокирующий фасад над `TelegramBot`. Каждый `sendMessage`/`editMessage`/`deleteMessage` ждёт per-chat + global slot, потом делает сетевой вызов | + +Задуманная защита 429: by construction. Путь, который мог бы выпустить burst, не +существует — caller блокируется до тех пор, пока оба окна (per-chat и global) не +свободны. Если ждать дольше `maxAcquireWaitMs` (по умолчанию 60с) — fail-stop, +метод возвращает `null` / `false`, чтобы зависание не корраптило Reactor pipeline. + +Квоты (валидируются при старте, не используются в runtime): +- private chat (`chatId > 0`) — `privateChatPerSecond`, дефолт 1/s +- group/supergroup (`chatId < 0`) — `groupChatPerMinute`, дефолт 20/min +- per-bot global cap — `globalPerSecond`, дефолт 30/s + +### Сильные стороны (только как design) + +- Domain-driven: `AssistantTurn` отражает бизнес-понятие «один ход агента», а не транспортный stream. +- Простота защиты 429: один блокирующий фасад с двумя квотами — нечего собирать из четырёх слоёв. +- Тестируемость на уровне дизайна: `TelegramRateLimitedBot` принимает `LongSupplier clock` + `Sleeper sleeper`, что позволяет virtual time в unit-тестах rate-limit поведения. Переиспользуемый паттерн. +- `TelegramAssistantTurnEndToEndTest` (250 строк) драйвит реальный стек View+RateLimitedBot+AssistantTurn на mocked `TelegramBot`. «End-to-end» здесь — относительно стека из трёх классов, а не относительно production pipeline. + +### Слабые стороны школы 5-5 + +- **Главное:** ничего из перечисленного не подключено в `TelegramMessageHandlerActions`. Не «дефект кода», а «PR не сделан до конца». +- **P1 — race в порядке резервации** (`TelegramRateLimitedBot:179`, по `experiment2_claude.md`). Per-chat slot бронируется до ожидания глобального; пока caller спит на global queue, per-chat-окно успевает истечь, и следующий вызов снова получает per-chat slot — два реальных вызова уходят back-to-back и могут вызвать 429. Фикс: резервировать per-chat slot ПОСЛЕ выхода из global wait. Дефект существует только в задуманном пути, в production не проявляется (потому что путь не подключён). +- **P2 — stale answer chunks** (`TelegramAssistantTurnView:180-194`). Если streamed partial answer уже открыл несколько answer-сообщений, а финальный layout короче, цикл редактирует только нужный префикс и не удаляет лишние Telegram-сообщения. В production не проявляется (view не используется). +- **P2 — превышение лимита 4096 символов в status bubble** (`TelegramAssistantTurnView:147`). В SHOW_ALL режиме `renderStatus()` возвращает весь transcript одним сообщением. Финальные ответы режутся по `maxMessageLength`, статус — нет. В production не проявляется. +- Блокировка caller'а может задушить event loop, если бот работает в reactive-контексте. + +## Сравнение по критериям + +Сравнение разделено на два измерения: «как design» (если бы оба PR были одинаково +интегрированы) и «как PR» (фактический вклад в продукт). Это разделение +существенно — потому что у 5-5 design без интеграции, и победители в двух +таблицах разные. + +### A. Design (концептуальный) + +| Критерий | 5-3 (v1 + v2) | 5-5 | Победитель | +|---|---|---|---| +| Концептуальная простота | 4 слоя (Model+View+Pacer+Sender) или async-dispatcher с coalescing | 2 узла (`AssistantTurn` + `RateLimitedBot`) | 5-5 | +| Защита от 429 (как задумано) | оптимистическая, через интервалы + retry-after | by-construction блокировка | 5-5 | +| Domain-driven дизайн | Model названа по транспорту (Stream) | `AssistantTurn` — domain-понятие | 5-5 | +| Глобальная квота Telegram (как задумано) | нет в v2 / есть в v1 | есть | 5-5 (с v1 наравне) | +| Тест-паттерны | стандартные unit-тесты | virtual clock+sleeper в `TelegramRateLimitedBot` | 5-5 | +| Реактивность / non-blocking | v1: async через `CompletableFuture` | блокирующий sync | 5-3 v1 | + +В измерении «design» 5-5 действительно сильнее. Но это не закрывает задачу. + +### B. PR (фактический вклад) + +| Критерий | 5-3 | 5-5 | Победитель | +|---|---|---|---| +| **Интеграция в production pipeline** | **есть** (`TelegramMessageHandlerActions` переписан, новые beans подключены) | **нет** (новые классы изолированы, 0 изменений в `command/` и autoconfig) | **5-3** | +| Реальная защита от 429 у юзера | ChatPacer per-chat + retry-after | как в `fsm-5`: только debounce, без global quota и retry-after | 5-3 | +| Что увидит юзер после merge | новый pipeline (status / candidate / confirmed) | то же поведение, что в `fsm-5` | 5-3 | +| Тяжесть оставшейся работы до merge | удалить ~600 строк dead code + зафиксить race в singleton view | реализовать интеграцию с нуля + зафиксить P1/P2 + переписать существующие тесты на новую модель | 5-3 | +| Регрессионный риск | мёртвые ветви в `TelegramMessageHandlerActions` могут сломать компиляцию при правках контекста | `TelegramProperties.RateLimit` валидируется при старте без потребителя — невалидный конфиг роняет приложение | сравнимо | +| Тяжесть найденных дефектов в активном пути | CRITICAL race в singleton view (`statusRenderedOffset`) | дефекты P1/P2 существуют только в неподключённом коде, в проде не проявляются | 5-3 (CRITICAL живой), 5-5 (всё дремлет) | + +В измерении «PR» 5-5 даёт нулевой вклад в решение задачи. 5-3 — реальный, но +дефектный. + +## Вердикт + +**Для merge брать 5-3.** Решение исходной задачи (429) у пользователя +улучшается только на этой ветке. У 5-5 — концептуально более чистый дизайн, +но как вклад в продукт это spike: 1623 строки лежат на полке, юзер не видит +никаких изменений по сравнению с `fsm-5`. + +Если оценивать по критерию «вклад в решение исходной задачи»: + +- 5-3: дефектная, но реально работающая интеграция с per-chat pacing и retry-after. +- 5-5: design без подключения. Балл за вклад — ноль; балл за дизайн — высокий, но + его нельзя обналичить, не сделав отдельную работу по интеграции. + +Это не отменяет того, что концептуально 5-5 правильнее. Но «правильнее как идея» +не равно «полезнее как PR». При следующей итерации архитектуры стоит подобрать +из 5-5 концепции (`AssistantTurn` как domain-объект, блокирующий бот by +construction, virtual-clock-тесты) и применить их в новой ветке поверх вычищенного +5-3 — но это уже отдельная работа, не часть merge-окна для текущего фикса 429. + +### Рекомендация + +Двигаться на 5-3 в следующем порядке. + +1. **Удалить dead code** в `TelegramMessageHandlerActions`: старое дерево + `handleAgentStreamEvent`, `handlePartialAnswer`, `promoteTentativeAnswer`, + `editTentativeAnswer`, `rollbackAndAppendToolCall`, `forceFinalAnswerEdit` + и связанные. Удалить тесты, которые их валидируют через reflection + (`TelegramMessageHandlerActionsTentativeEditTest`). +2. **Зафиксить CRITICAL race** в `TelegramAgentStreamView.statusRenderedOffset`: + вынести поле из singleton-bean в `MessageHandlerContext` (request-scoped) или + в саму `TelegramAgentStreamModel`. View должен стать stateless. +3. **Удалить misleading-настройку** `agent-stream-edit-min-interval-ms` из + `TelegramProperties` и всех `application*.yml` после удаления dead-code, + потому что её consumer'ы живут только в удаляемых ветках. +4. **Решить судьбу** `MessageHandlerErrorType.TELEGRAM_DELIVERY_FAILED`: либо + связать его с FSM-переходом и локализованным сообщением, либо удалить. +5. **Прокинуть `ObjectMapper`** через конструктор `TelegramAgentStreamModel` + вместо `new ObjectMapper()` per-request. + +5-5 на этом этапе можно либо удалить как `spike` (если идеи будут реализованы +в новой ветке), либо оставить как reference для следующей итерации архитектуры. +Самостоятельной merge-ценности у неё нет. + +5-3 после этих фиксов закрывает исходную задачу 429 на уровне per-chat pacing ++ retry-after, что заметно лучше базы `fsm-5`. Глобальная квота Telegram +(≈30 msg/s на бот) при этом остаётся незакрытой — это отдельный TODO, +кандидат на следующую итерацию (где как раз можно подобрать `TelegramRateLimitedBot` +из 5-5 как готовый компонент). diff --git a/docs/review/experiment2_claude.md b/docs/review/experiment2_claude.md new file mode 100644 index 00000000..45691d17 --- /dev/null +++ b/docs/review/experiment2_claude.md @@ -0,0 +1,17 @@ +• The new rate limiter can still violate per-chat Telegram quotas under global saturation, and the assistant turn view leaves stale answer messages and can exceed Telegram's +status-message size limit. These are user-visible correctness issues in the added functionality. + +Full review comments: + +- [P1] Reserve the chat slot at the actual send time — opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramRateLimitedBot.java:179 + When the global quota is saturated, this advances nextAllowedAtMs before awaitGlobalSlot() may block. A first request can wait in the global queue and then send much later, + while the next request for the same private/group chat immediately passes the per-chat check because the interval expired during the global wait, so two actual Telegram calls + can still be emitted back-to-back and trigger the 429 this facade is meant to prevent. +- [P2] Remove stale answer chunks when the final layout shrinks — opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAssistantTurnView.java:180-194 + If a streamed partial answer has already opened multiple answer messages and a later consolidated FINAL_ANSWER is shorter, or the turn enters ERROR, desiredAnswers.size() can + become smaller than answerMessageIds.size(). This loop only edits/sends the desired prefix and never deletes or clears the extra Telegram messages, leaving stale partial + chunks visible after the final reconcile. +- [P2] Keep the status bubble within Telegram's message limit — opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAssistantTurnView.java:147 + In SHOW_ALL mode, or with many/large tool calls, renderStatus() returns the entire accumulated transcript as one Telegram message while only final answers are split by + maxMessageLength. Once the status HTML exceeds Telegram's 4096-character limit, sendMessage/editMessage fails and the live status either never appears or stops updating for + long turns. \ No newline at end of file diff --git a/docs/review/experiment2_codex.md b/docs/review/experiment2_codex.md new file mode 100644 index 00000000..e77f2853 --- /dev/null +++ b/docs/review/experiment2_codex.md @@ -0,0 +1,150 @@ +Готов выдать review. Лимит API в порядке, подтверждённые проблемы найдены. + +★ Insight ───────────────────────────────────── +- В этом PR одновременно выполняются две вещи: интродукция чистой провайдер-нейтральной model+view архитектуры + (TelegramAgentStreamModel/TelegramAgentStreamView/TelegramChatPacer) и удаление вызова старой системы. Однако сам код старой системы не удалён — около 600 строк в + TelegramMessageHandlerActions стали мёртвыми. +- В Spring @Bean-методы создают singleton по умолчанию, поэтому любое мутабельное поле в bean-классе становится разделяемым между чатами. Это легко пропустить в коде, который + раньше держал состояние в request-scoped MessageHandlerContext. +- Добавление новой константы в enum не делает её user-visible — нужно ещё связать её с FSM-переходом или error-рендером, иначе она просто заполняется и игнорируется. + ───────────────────────────────────────────────── + +Review PR #28 (Stream By Codex) против fsm-5 + +Что делает PR + +Один коммит 6cf4af5, +1186 / −466 строк. Перепиливает Telegram-вьюшку агентского стрима: + +1. Вводит провайдер-нейтральную модель TelegramAgentStreamModel (буферы статуса, candidate-ответа и confirmed-ответа), которую кормят AgentStreamEvent-ы. +2. Вводит TelegramAgentStreamView — рендерит снапшоты модели в Telegram (status-сообщение редактируется в месте; answer-сообщение создаётся только после + FINAL_ANSWER/MAX_ITERATIONS). +3. Вводит per-chat пейсер TelegramChatPacer (tryReserve / reserve) для соблюдения rate-limit Telegram (1с в private, 3с в группах по умолчанию). +4. Добавляет «надёжные» send/edit (sendHtmlReliableAndGetId, editHtmlReliable) с распарсиванием retry_after из 429 в TelegramMessageSender. +5. Меняет PersistentKeyboardService.sendKeyboard — теперь он тоже резервирует слот пейсера. +6. Поведенчески: больше нет «спекулятивного» tentative-bubble; partial answer держится только в Java-модели и попадает в чат как final только после подтверждения. + + --- +CRITICAL + +1. TelegramAgentStreamView.statusRenderedOffset — гонка между чатами на singleton bean + +opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamView.java:22 + +public final class TelegramAgentStreamView { +... +private int statusRenderedOffset; + +Bean регистрируется как обычный @Bean в TelegramCommandHandlerConfig.java:241 → singleton. Поле statusRenderedOffset мутабельно, не volatile, не synchronized, и используется в +flushStatus() для вычисления среза fullHtml.substring(statusRenderedOffset). Два параллельных агентских запроса в разных чатах перезапишут друг другу offset, и в одном из +чатов в Telegram уйдёт неправильный кусок HTML (или IndexOutOfBounds в пограничном случае). При редактировании сообщения после ротации это особенно вредно — offset обновляется +в момент рассечения, и сосед может получить не свой остаток. + +Фикс: перенести statusRenderedOffset в MessageHandlerContext (request-scoped) или в саму TelegramAgentStreamModel. View должен быть stateless. + +--- +HIGH + +2. ~600 строк мёртвого кода в TelegramMessageHandlerActions + +После замены вызова handleAgentStreamEvent на handleAgentStreamModelEvent (TelegramMessageHandlerActions.java:415) старое дерево не имеет ни одного caller-а в production: + +- handleAgentStreamEvent (488) +- handlePartialAnswer (575) +- containsToolMarker, handleEmbeddedToolMarker, tailAsPlainOverlay +- applyUpdate ветки для legacy RenderedUpdate типов +- promoteTentativeAnswer (919), editTentativeAnswer (948), forceFinalAnswerEdit (987), rollbackAndAppendToolCall (996) +- finalizeAfterStream (1022), handleStreamError (1035) +- editStatusThrottled (1051), pacedForceFlushStatus (905), appendToolCallBlock, replaceTrailingThinkingLineWithEscaped, appendToStatusBuffer, rotateStatusIfNeeded +- Поле agentStreamRenderer и инжекция TelegramAgentStreamRenderer (а с ним и сам @Bean telegramAgentStreamRenderer) + +Единственный «живой» обращающийся к этому хвосту код — TelegramMessageHandlerActionsTentativeEditTest, который вызывает editTentativeAnswer через reflection, плюс +@Disabled-нутый TwoMessageOrchestration. Это значит: + +- Зрительное покрытие тестами обманчивое — тесты проверяют поведение, которого больше нет в горячем пути. +- Любая правка контекста ломает компиляцию dead-кода и тратит ревью-время. +- Поведение agentStreamEditMinIntervalMs (см. ниже) тоже становится фантомным. + +Согласно AGENTS.md («не оставлять half-finished implementations»), это надо убрать в этом же PR — либо уже переключаемся на model+view, либо оставляем переключатель и тогда +это не dead-код. + +3. Тесты на now-orphaned поведение продолжают исполняться + +TelegramMessageHandlerActionsTentativeEditTest использует reflection (getDeclaredMethod("editTentativeAnswer", ...)) и валидирует rollback-механизм, который из реального event +flow больше не вызывается. Смесь зелёных тестов на мёртвое поведение и зелёных на новое создаёт ложную уверенность. Удалить вместе с пунктом 2. + +--- +MEDIUM + +4. MessageHandlerErrorType.TELEGRAM_DELIVERY_FAILED устанавливается, но нигде не обрабатывается + +grep по всему репо находит ровно два места: определение enum и setErrorType(...) в TelegramMessageHandlerActions:431. Ни FSM, ни error-renderer не маппят +TELEGRAM_DELIVERY_FAILED ни в локализованное сообщение, ни в специальную ветку — наружу ведёт себя как GENERAL. Либо допишите обработку (например, лог + повторная попытка / +уведомление пользователю), либо удалите enum-значение. Кстати, при этом ещё и RuntimeException TelegramDeliveryFailedException бросается-ловится только присвоением полю, без +выкидывания вверх. + +5. agent-stream-edit-min-interval-ms стало misleading + +В TelegramProperties.java:115-125 javadoc теперь утверждает, что параметр контролирует «UX phase pacing between structural agent stream transitions». Но все его три consumer-а +живут только в dead-коде из пункта 2 (pacedForceFlushStatus, editStatusThrottled, editTentativeAnswer). После очистки dead-кода — снести и эту настройку из +TelegramProperties, application.yml, application-test.yml, application-integration-test.yaml. Иначе оператор копипастит в конфиги мёртвую ручку. + +6. PersistentKeyboardService.sendKeyboard теперь блокирующий + +Был «отправь-и-залогируй-если-упало». Стал блокирующим до defaultAcquireTimeoutMs + intervalMs(chatId) (в группе ≈ 4000 мс). Тест +sendKeyboard_waitsOneChatPacingIntervalAfterStreamBeforeSkipping это фиксирует, но в javadoc метода ничего нет. Добавьте короткое примечание про блокировку и про то, что +keyboard может быть пропущен после долгой стрим-сессии. + +7. TelegramAgentStreamModel создаёт ObjectMapper per-request + +TelegramAgentStreamModel.java:34-38 — конструктор по-умолчанию делает new ObjectMapper(). В Spring уже есть бин ObjectMapper (рендерер в нём же используется). Прокидывайте +через конструктор TelegramMessageHandlerActions → модель. ObjectMapper тяжёл и thread-safe — нет смысла плодить. + +--- +LOW + +8. TelegramChatPacerImpl.ChatSlot.notifyAll() на success-path + +В tryReserve/reserve после успешного захвата слот сразу делает notifyAll(). Но waiter-ы при пробуждении проверят nowMs < nextAllowedAtMs (которое только что отодвинули вперёд) +и снова уйдут в wait. Wake-up здесь чистая трата CPU — notifyAll() уместен только если вы измените логику на «отдавать слот»/«отменять резерв». Удалить или добавить +комментарий «for future cancel-aware path». + +9. TelegramMessageSender.sleepForRetryAfterIfPossible блокирует поток внутри Reactor concatMap + +Сейчас всё стекает в blockLast() в FSM-thread, поэтому Reactor worker не пинятся. Но Thread.sleep(retryAfterMs) до 5 с — комментарий бы тут не помешал, иначе при будущем +переходе на честный non-blocking pipeline случайно подвесим scheduler. + +10. TelegramMessageSender.parseRetryAfterSeconds объявлен public + +Используется только внутри класса. Сделать private чтобы не плодить API surface. + +11. TelegramAgentStreamModel.applyThinking дважды строит statusHtml.toString() + +TelegramAgentStreamModel.java:107 — две инвокации toString() подряд, на больших буферах это аллокации. Сохраните результат в локалку. + +12. TelegramAutoConfig.java — пустая строка перед } + +Единственное изменение в файле — добавление пустой строки. Уберите шум из diff. + +13. Style-несоответствие + +TelegramChatPacerImpl создаётся как public class без final, без @RequiredArgsConstructor (хотя у TelegramChatPacer единственная зависимость — это TelegramProperties). Соседние +Impl-классы используют @RequiredArgsConstructor. Согласно AGENTS.md → Service Layer, это рекомендованный стиль. + +--- +Хорошее + +- Чистое разделение model/view. TelegramAgentStreamModel тестируется без Telegram-моков; TelegramAgentStreamModelTest покрывает три ключевых сценария (candidate→confirmed, + pre-tool fold, provider-neutrality). Это правильный инвариант — раньше тесты лежали через mock-edges Telegram-bot-а. +- Per-chat пейсер правильно отделён от per-request состояния, корректно использует ConcurrentHashMap.computeIfAbsent + synchronized per slot — race-condition нет. +- Обработка retry_after из TelegramApiRequestException.getParameters().getRetryAfter() с fallback на regex — прагматично; парс retry-after из текста ошибки реально нужен, + потому что telegrambots иногда не заполняет parameters. +- Документация (TELEGRAM_MODULE.md) обновлена синхронно с поведением — соответствует правилу из AGENTS.md. +- YAML-конфиги добавлены во все три профиля (main / test / integration-test). С Validated+@NotNull пропуск конфига сразу упадёт на старте, не молчком. + + --- +Вердикт + +Block. Пункт 1 (CRITICAL race на singleton bean) — реальный баг для конкурентного нагрузочного сценария. Пункты 2-3 (HIGH dead-code/dead-tests) — нужны в этом же PR, иначе +следующий ревью утонет в шуме. Остальное — можно фоллоу-ап. + +После фикса 1-3 и подчистки 4-5 PR можно мёрджить — архитектурно изменение правильное и улучшает тестируемость. \ No newline at end of file diff --git a/docs/team/td-1-stream-view-state-isolation.md b/docs/team/td-1-stream-view-state-isolation.md new file mode 100644 index 00000000..5b7aa2f5 --- /dev/null +++ b/docs/team/td-1-stream-view-state-isolation.md @@ -0,0 +1,369 @@ +--- +slug: td-1-stream-view-state-isolation +title: "TD-1: Isolate per-stream offset state from singleton TelegramAgentStreamView" +owner: ngirchev +architect: orchestrator +created: 2026-04-26 +status: done +base_branch: feature/td-1-stream-view-state-isolation +--- + +# TD-1: Isolate per-stream offset state from singleton TelegramAgentStreamView + +## 1. Problem Statement + +TelegramAgentStreamView is a Spring singleton bean (registered via @Bean in TelegramCommandHandlerConfig:241-246) but holds a mutable `int statusRenderedOffset` field at `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamView.java:22`. The field is read at lines 55, 58, 74 and written at lines 56, 74 inside `flushStatus(...)`. + +Because the bean is shared across all concurrent agent streams in different chats: +- User A's status rotation writes `statusRenderedOffset = 1500` +- Concurrent User B with a much shorter `model.statusHtml()` reads that offset → `substring(1500)` on a shorter string → either `IndexOutOfBoundsException` or wrong content sent to Telegram +- The guard at line 55 (`if (statusRenderedOffset > fullHtml.length()) statusRenderedOffset = 0;`) is not atomic — check-then-act races between threads +- `TelegramChatPacer` serializes operations within a single `chatId` but provides NO ordering between different chats + +This was identified as TD-1 during the post-mortem of the "На ос" duplication bug fixed in commit c2d6a66, where audit of `TelegramAgentStreamView` surfaced the singleton-state issue as a separate concern that must be addressed before merging the `fsm-5-3-telegram-stream-view` feature to master. + +## 2. Business Context & Goals + +Move `statusRenderedOffset` from the singleton `TelegramAgentStreamView` to per-request state living in `MessageHandlerContext` (which already holds `statusMessageId`, `tentativeAnswerMessageId`, `lastStatusEditAtMs`, etc. — natural neighbor by concept). + +After the fix: +- `TelegramAgentStreamView` declares only `final` fields → truly stateless singleton +- The offset is read/written via `ctx.getStatusRenderedOffset()` / `ctx.setStatusRenderedOffset(int)` throughout `flushStatus` +- A regression test demonstrates two concurrent `MessageHandlerContext` instances flushing through the same View instance without state leakage + +## 3. Non-Goals / Out of Scope + +- TD-2: 16-arg constructor refactor of `TelegramMessageHandlerActions` — separate /team session +- TD-4: IT wiring duplication across `TelegramFixtureConfig` / `TelegramMockGatewayIT` / `MessageTelegramCommandHandlerIT` — separate /team session +- TD-5: Legacy tentative-bubble dead code in `TelegramMessageHandlerActions` (`promoteTentativeAnswer`, `editTentativeAnswer`, `forceFinalAnswerEdit`, `rollbackAndAppendToolCall`, `handlePartialAnswer`, `handleAgentStreamEvent` and related constants) — separate /team session +- Any change to streaming UX behavior — this is a pure isolation refactor, no observable behavior change for end users +- Conversion of `MessageHandlerContext` to immutable / record — out of scope (`StateContext` mutable accumulator pattern documented in `.claude/rules/java/coding-style.md`) + +### Future tech-debt items surfaced during TD-1 discovery (NOT addressed in this PR) + +The Phase 1 singleton-bean audit (team-explorer Round B) of `opendaimon-telegram` found additional concurrency / lifecycle issues. They are filed here so the next /team session can pick them up; explicitly out of scope for THIS PR (per user choice in Phase 1 Round A: "Строго TD-1, без побочных правок"). + +- **TD-future-A: `InMemoryModelSelectionSession` — atomic cache-aside pattern (MEDIUM).** Lines 26-31 use non-atomic `get()+put()`. Under concurrent requests for the same userId, the AI-gateway fetcher may be called more than once and the second result silently overwrites the first. Replace with `computeIfAbsent` or CAS-equivalent. Impact: redundant rate-limited AI calls, not data corruption. +- **TD-future-B: `TelegramChatPacerImpl.slots` unbounded growth (LOW).** `ConcurrentHashMap` grows forever as new chatIds arrive; entries are never evicted. A `Caffeine` cache with time-based expiry would bound memory. Impact: long-running bot leaks memory in proportion to unique chat count. +- **TD-future-C: `TelegramBotMenuService.currentMenuVersionHash` — test isolation (LOW).** `volatile` reference set once via DCL pattern at construction; correct at runtime, but stateful under Spring test-context reuse → could produce stale hash false-positives in `@DirtiesContext` cascades. Consider `@PostConstruct` re-init or scope to per-context. + +Audit confirmed only 1 AT-RISK bean (TelegramAgentStreamView — current TD-1 scope). Count is under the systemic threshold (5 AT-RISK beans = ArchUnit/lint rule warranted), so per-bean fixes are appropriate going forward. + +## 4. Existing State (Phase 1 Discovery) + +### MessageHandlerContext (target for the new field) + +Source: `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerContext.java` + +- **Class shape:** `public final class MessageHandlerContext implements StateContext` (line 38). NO Lombok anywhere — every accessor is explicit Java. Mix of `final` (immutable-after-construction collaborators set via constructor) and non-final (FSM-actions / view-populated render state) fields. +- **NOT Serializable.** Does not implement `java.io.Serializable`; `StateContext` is an external library marker but no persistence path uses `MessageHandlerContext`. Verified via `find_referencing_symbols` — no `ObjectOutputStream`, no Jackson read/write of the context. Adding a primitive int field carries zero schema-migration risk. +- **Lifecycle:** Instantiated fresh per Telegram message in `MessageTelegramCommandHandler.handleInner():83-84`, lives for one `handlerFsm.handle(ctx, ...)` invocation, then GC'd. No pooling, no reuse — per-request scope is the same isolation guarantee we need. +- **Existing precedent for primitive-int progressive cursor:** `private int toolMarkerScanOffset;` at line 112 (an offset into `tentativeAnswerBuffer` tracking how far tool-marker scanning has progressed). Accessor pair at lines 392-398 uses hand-written `getToolMarkerScanOffset()` / `setToolMarkerScanOffset(int)`. Reset via `this.toolMarkerScanOffset = 0;` inside `resetTentativeAnswer()` (line 407). +- **Render-state field neighborhood (where the new field belongs):** `Integer statusMessageId` (line 92), `final StringBuilder statusBuffer` (line 93), `long lastStatusEditAtMs` (line 94, set by `markStatusEdited()` at line 334). The new `statusRenderedOffset` is conceptually a sibling of these three. + +### TelegramAgentStreamView (source of the migrated field) + +Source: `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamView.java` + +- **Singleton bean** registered via `@Bean` in `TelegramCommandHandlerConfig:241-246`. Spring default scope = singleton. +- **Mutable field:** `private int statusRenderedOffset;` at line 22. Read at lines 55, 58, 74. Written at lines 56, 74 inside `flushStatus(...)`. Guard at line 55 (`if (statusRenderedOffset > fullHtml.length()) statusRenderedOffset = 0;`) is non-atomic check-then-act — races between concurrent threads handling different chats. +- **Other fields are safe:** `messageSender`, `telegramChatPacer`, `telegramProperties` all `final`. Removing `statusRenderedOffset` makes the View truly stateless. +- **Concurrency boundary:** `TelegramChatPacer` serializes operations within a single `chatId` but provides NO ordering between different chats. So concurrent `flushStatus` calls from chats A and B race on the shared offset. + +### Singleton-bean audit summary (opendaimon-telegram, full module sweep) + +| Bean class | Mutable field | Severity | Disposition | +|---|---|---|---| +| `TelegramAgentStreamView` | `int statusRenderedOffset` | HIGH | TD-1 — fixed in this PR | +| `InMemoryModelSelectionSession` | non-atomic `get()+put()` on `userCache` | MEDIUM | TD-future-A (deferred) | +| `TelegramChatPacerImpl.slots` | unbounded `ConcurrentHashMap` growth | LOW | TD-future-B (deferred) | +| `TelegramBotMenuService.currentMenuVersionHash` | volatile DCL field, test-context reuse risk | LOW | TD-future-C (deferred) | +| `TelegramAgentStreamRenderer` | `final ObjectMapper` | SAFE | n/a | +| `TelegramMessageSender` | all `final` thread-safe collaborators | SAFE | n/a | +| `TelegramChatPacerImpl.ChatSlot.nextAllowedAtMs` | `synchronized`-protected `long` | SAFE | n/a (correct monitor discipline) | +| `TypingIndicatorService.activeTypingIndicators` | `final ConcurrentHashMap`, replace-not-mutate values | SAFE | n/a | +| `TelegramMessageCoalescingService.pendingByKey` | `final ConcurrentHashMap`, immutable record values | SAFE | n/a | + +### Phase 1 Round A user decisions (constraints for Phase 2 architecture) + +- **Concurrency test style:** real threads + `CountDownLatch` (`Executors.newFixedThreadPool(2)`, both threads await a shared latch, then perform parallel `flushStatus(ctx, model)` calls on a single `TelegramAgentStreamView` instance with two distinct `MessageHandlerContext` instances; assert each context's offset advances independently). Rationale: catches real interleavings; deterministic-only simulator was rejected because it could miss subtle JVM memory-model edge cases. +- **Adjacent singleton-bean issues found by Explorer 2:** logged as TD-future-A/B/C in §3, NOT fixed in this PR. Strict TD-1 scope confirmed. + +## 5. Proposed Architecture + +TO-BE diff vs §4: `TelegramAgentStreamView` becomes a truly stateless singleton (only `final` fields). The progressive-render cursor `statusRenderedOffset` migrates from a singleton field into `MessageHandlerContext`, joining its conceptual siblings `statusMessageId` (Integer), `statusBuffer` (StringBuilder), and `lastStatusEditAtMs` (long). Each per-message context already provides the per-request scope needed for isolation. + +### 5.1 Component diagram / flow + +#### AS-IS (race) +```mermaid +sequenceDiagram + participant ThreadA as Thread A (chatId=100) + participant ThreadB as Thread B (chatId=200) + participant View as TelegramAgentStreamView
(singleton, statusRenderedOffset=int) + participant Sender as TelegramMessageSender + + ThreadA->>View: flushStatus(ctxA, modelA) + Note over View: statusRenderedOffset := 0 + ThreadA->>View: read statusRenderedOffset (=0) + ThreadA->>Sender: editHtml(chatA, html.substring(0)) + Note over View: statusRenderedOffset := 1500 (after rotation) + ThreadB->>View: flushStatus(ctxB, modelB) + ThreadB->>View: read statusRenderedOffset (=1500) + Note over ThreadB: modelB.statusHtml().length() == 200
1500 > 200 → guard resets to 0,
but read value already used: substring(1500) on 200-char string
→ IndexOutOfBoundsException OR truncated content + ThreadB->>Sender: editHtml(chatB, ???) +``` + +#### TO-BE (isolated) +```mermaid +sequenceDiagram + participant ThreadA as Thread A (chatId=100) + participant ThreadB as Thread B (chatId=200) + participant View as TelegramAgentStreamView
(singleton, all fields final) + participant CtxA as MessageHandlerContext A
(per-request, statusRenderedOffset=0) + participant CtxB as MessageHandlerContext B
(per-request, statusRenderedOffset=0) + participant Sender as TelegramMessageSender + + ThreadA->>View: flushStatus(ctxA, modelA) + View->>CtxA: getStatusRenderedOffset() (=0) + ThreadA->>Sender: editHtml(chatA, html.substring(0)) + View->>CtxA: setStatusRenderedOffset(1500) + ThreadB->>View: flushStatus(ctxB, modelB) + View->>CtxB: getStatusRenderedOffset() (=0, untouched by Thread A) + ThreadB->>Sender: editHtml(chatB, html.substring(0)) + View->>CtxB: setStatusRenderedOffset(...) + Note over View: View itself holds NO mutable state.
Each request's offset lives on its own Context. +``` + +### 5.2 Module impact + +- **`opendaimon-telegram`** (production): + - `service/TelegramAgentStreamView.java` — remove `private int statusRenderedOffset;` field; replace 5 read/write occurrences inside `flushStatus(...)` with `ctx.getStatusRenderedOffset()` / `ctx.setStatusRenderedOffset(int)`. After change, every instance field is `final`. + - `command/handler/impl/fsm/MessageHandlerContext.java` — add `private int statusRenderedOffset;` declaration adjacent to `lastStatusEditAtMs` (line 94 area); add hand-written `getStatusRenderedOffset()` / `setStatusRenderedOffset(int)` accessors adjacent to `markStatusEdited()` (line 334 area). NO Lombok (matches class convention). Field defaults to 0 implicitly (matches `toolMarkerScanOffset` precedent at line 112). +- **`opendaimon-telegram`** (tests): + - new file `src/test/java/.../service/TelegramAgentStreamViewConcurrencyTest.java` — exercises two real threads through one View instance with two distinct Contexts, asserts offset isolation. +- **`opendaimon-telegram`** (docs): + - `TELEGRAM_MODULE.md` — add one sentence in the "Agent Streaming Internals" section noting that `TelegramAgentStreamView` is stateless and the per-stream render offset lives on `MessageHandlerContext`. +- **`opendaimon-app`**: NO changes. `TelegramAgentStreamView`'s public constructor signature is unchanged → IT wiring (the three files patched in commit 2d909db) is unaffected. This is a deliberate design choice — TD-4 (IT wiring duplication) is out of scope. +- **No other modules touched.** + +### 5.3 Data model +— not applicable. No entities, no migrations, no JPA changes. + +### 5.4 Configuration +— not applicable. No new `open-daimon.*` properties, no `FeatureToggle` constants. The fix is purely a state-ownership refactor. + +### 5.5 Metrics +— not applicable. No new metrics on `OpenDaimonMeterRegistry`. (Adding a metric like `telegram.agent.stream.race-recovery.count` was considered but rejected — after the fix, races by construction cannot occur, so a recovery counter would always read 0.) + +### 5.6 AI integration +— not applicable. No AI calls; this is pure infrastructure plumbing. + +## 6. Alternatives Considered + +### Option A — Move `statusRenderedOffset` into `MessageHandlerContext` +- **Pros:** + - Mirrors the existing `toolMarkerScanOffset` precedent at `MessageHandlerContext:112` (same pattern, same module). + - Per-request lifecycle (`MessageTelegramCommandHandler.handleInner():83-84` creates fresh Context per message) gives natural isolation — no synchronization primitives needed. + - `TelegramAgentStreamView` becomes a true stateless singleton (only `final` fields), trivially safe under any future scaling (multiple Spring contexts, classpath reloading). + - Zero impact on public API — View's constructor unchanged → IT wiring unaffected (preserves TD-4 separation). + - Zero serialization risk (Phase 1 confirmed Context is not persisted anywhere). +- **Cons:** + - One extra field on an already-large `MessageHandlerContext` class. Mitigated by the natural conceptual fit with sibling fields `statusMessageId`/`statusBuffer`/`lastStatusEditAtMs`. + - View now reads/writes through accessor calls instead of direct field access — negligible JIT overhead. + +### Option B — Convert `statusRenderedOffset` to a `ThreadLocal` on the View +- **Pros:** + - Smaller diff: only `TelegramAgentStreamView` changes, no Context modification. + - Survives across method calls within the same thread without explicit threading. +- **Cons:** + - **DEALBREAKER for this codebase**: agent streaming uses `reactor.core.publisher.Mono`/`Flux` (`TelegramMessageHandlerActions.generateAgentResponse():410-420` chains a `.concatMap` over `executeStream(...)`). Reactor schedulers freely reassign work between threads; ThreadLocal contents are not propagated unless `Schedulers.onScheduleHook()` is configured project-wide, which it is not. + - Requires explicit `ThreadLocal.remove()` in a finally-block to prevent classloader leaks in the application server — error-prone. + - Singleton bean still has mutable state, just hidden behind ThreadLocal — future maintainers might add another mutable field thinking the precedent is set. + +### Option C — Make `TelegramAgentStreamView` a prototype-scoped or request-scoped bean +- **Pros:** + - Conceptually cleanest: each agent stream gets its own View instance, all fields naturally per-request. +- **Cons:** + - **DEALBREAKER**: `TelegramAgentStreamView` is injected as a `final` field into `TelegramMessageHandlerActions` (constructor param at `TelegramCommandHandlerConfig:265`) and similar IT factories (3 places). All 4 injection points expect a singleton; switching to prototype scope without `ObjectProvider` indirection would silently get only the first-resolved instance, defeating the purpose. + - Significant refactor — would need to wrap all injections with `ObjectProvider`, change every call from `agentStreamView.flush(...)` to `agentStreamView.getObject().flush(...)`. Touches 4+ files and contradicts the "strict TD-1 scope" non-goal. + - Spring `@RequestScope` requires a Servlet request — not applicable here (Telegram updates are not HTTP requests). + +### **Chosen: Option A** — move the field to `MessageHandlerContext`. + +Justification: lowest-blast-radius change with the strongest existing precedent (`toolMarkerScanOffset`). Eliminates mutable singleton state by construction (no synchronization primitives, no ThreadLocal cleanup, no Spring scope rewiring). Per-request scope is automatic from existing context lifecycle. Public API of View unchanged → TD-4 separation preserved. + +## 7. Risks & Mitigations + +| Severity | Risk | Mitigation | +|---|---|---| +| MEDIUM | A future contributor adds another mutable field to `TelegramAgentStreamView`, reintroducing the same anti-pattern. | Mark all View instance fields `final` after the change (compile-time enforcement); add a class-level Javadoc noting "stateless singleton — all per-request state lives on `MessageHandlerContext`." Optional: ArchUnit rule for `service.TelegramAgent*View` to enforce all-final fields (logged as TD-future-D below; not addressed here). | +| LOW | The `flushStatus` rotation path (lines 70-83 in current code) writes `statusRenderedOffset` on the same call as `setStatusMessageId(nextId)` — if accessor names are similar, an editor's auto-complete could swap them silently. | Naming chosen distinct (`setStatusRenderedOffset` vs `setStatusMessageId`); regression test asserts both fields advance correctly. | +| LOW | `resetTentativeAnswer()` (`MessageHandlerContext:401`) zeros `toolMarkerScanOffset`. If the streaming flow ever introduces a similar reset path for status state, `statusRenderedOffset` may need symmetric reset there. | Phase 1 explorer flagged this. Decision deferred to Phase 4 task breakdown: a one-line check in TASK comments asks the developer to grep for `resetTentativeAnswer` callsites and confirm that no status-message reset path currently exists; if it does, add `statusRenderedOffset = 0` to it. Currently no such callsite — `resetTentativeAnswer` is invoked only from `rollbackAndAppendToolCall` which leaves the status message untouched. | +| LOW | The new concurrency test using `Executors.newFixedThreadPool(2)` + `CountDownLatch` is inherently scheduler-dependent — under heavy CI load it could intermittently complete one thread before the other awaits the latch, weakening the race coverage without failing. | Use `CyclicBarrier(2)` instead of `CountDownLatch(1)` so both threads must rendezvous before either proceeds (stronger contention guarantee); set a 5-second JUnit `@Timeout` on the test method to fail loud rather than hang on regression; assert offset isolation via final per-Context state inspection (deterministic regardless of interleaving). | + +## 8. Non-Functional Constraints + +- **Performance:** Zero overhead. One additional `getfield`/`putfield` JIT-inlined operation replaces the previous direct singleton-field access. No allocations, no synchronization primitives. +- **Security:** Not applicable. No authentication / authorization / cryptography surface touched. +- **Concurrency:** This IS the non-functional improvement. Before: one shared `int` across all chats with non-atomic check-then-act, races between threads. After: per-request field on per-request context — concurrency safety by construction. No locks, atomics, or memory-model considerations needed (single thread accesses one Context per FSM run; `MessageHandlerContext` is documented as a `StateContext` mutable accumulator per `.claude/rules/java/coding-style.md`). +- **Backward compatibility:** Public APIs unchanged. `TelegramAgentStreamView`'s constructor signature, public methods (`flush`, `flushFinal`), and `@Bean` definition in `TelegramCommandHandlerConfig:241-246` are all preserved. `MessageHandlerContext` is internal to the `opendaimon-telegram` module and not part of any external SPI — adding a private field with public accessors is a non-breaking addition. +- **Migration strategy:** Not applicable. No persistent state involved (Phase 1 explorer confirmed `MessageHandlerContext` is not Serializable and is never persisted via Jackson or `ObjectOutputStream`). The change is purely in-memory; deploying the new build is sufficient — no data migration, no version-pinning, no rolling-upgrade ordering concerns. Old running JVMs simply finish their in-flight requests with the old (racy) singleton field, then exit normally; the new JVMs use the per-Context field. + +## 9. Requirements + +- [x] **REQ-1** — `TelegramAgentStreamView` declares zero mutable instance state. + - Acceptance: every instance field on `TelegramAgentStreamView` is declared with the `final` modifier. Verifiable by `grep -nE "^\s*private (?!final)" opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamView.java` returning zero matches, OR by reflection (`Arrays.stream(TelegramAgentStreamView.class.getDeclaredFields()).filter(f -> !Modifier.isStatic(f.getModifiers())).allMatch(f -> Modifier.isFinal(f.getModifiers()))` returns `true`). + - Verified by: `opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamViewConcurrencyTest.java#shouldDeclareOnlyFinalInstanceFields` (reflection invariant, `@BeforeAll`) + +- [x] **REQ-2** — `MessageHandlerContext` owns the per-stream render offset. + - Acceptance: `MessageHandlerContext` contains a `private int statusRenderedOffset` field placed adjacent to `lastStatusEditAtMs` (line 94 area), plus hand-written `public int getStatusRenderedOffset()` and `public void setStatusRenderedOffset(int statusRenderedOffset)` accessors (no Lombok — explicit Java methods, mirroring the `toolMarkerScanOffset` precedent at line 112). Default initial value is `0` (no initializer needed; relies on Java default for `int`). + - Verified by: `opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerContextTest.java#shouldRoundtripStatusRenderedOffset` + +- [x] **REQ-3** — Concurrent `flushStatus` calls on a single `TelegramAgentStreamView` instance with two distinct `MessageHandlerContext` instances do not leak offset state between contexts. + - Acceptance: a unit test (file: `opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamViewConcurrencyTest.java`) creates ONE `TelegramAgentStreamView` bean with mocked `TelegramMessageSender` and `TelegramChatPacer`, two distinct `MessageHandlerContext` instances (chatId=100, chatId=200) and two `TelegramAgentStreamModel` instances. Two threads (via `ExecutorService` with `CyclicBarrier(2)` rendezvous + `@Timeout(5, SECONDS)` on the test method) invoke `view.flushStatus(ctxA, modelA)` and `view.flushStatus(ctxB, modelB)` in parallel. After both threads return, the test asserts: `ctxA.getStatusRenderedOffset()` reflects ONLY chat A's model state, `ctxB.getStatusRenderedOffset()` reflects ONLY chat B's, and the two values would not match if they were sharing the same singleton field. + - Verified by: `opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamViewConcurrencyTest.java#shouldKeepStatusRenderedOffsetIsolatedAcrossConcurrentFlushes` + +- [x] **REQ-4** — Existing streaming behavior tests pass without modification. + - Acceptance: `./mvnw test -pl opendaimon-telegram -am -Dtest='TelegramAgentStreamModelTest,TelegramMessageHandlerActionsStreamingTest,TelegramAgentStreamRendererTest'` exits with code 0. + - Verified by: `./mvnw test -pl opendaimon-telegram -am -Dtest='TelegramAgentStreamModelTest,TelegramMessageHandlerActionsStreamingTest,TelegramAgentStreamRendererTest'` → 43 tests pass (7 + 17 + 19) + +- [x] **REQ-5** — Full fixture integration suite passes. + - Acceptance: `./mvnw clean verify -pl opendaimon-app -am -Pfixture` exits with code 0. No fixture test in `opendaimon-app/src/it/java/.../it/fixture/` regresses (use-case → fixture mapping per `.claude/rules/java/fixture-tests.md`). + - Verified by: `./mvnw clean verify -pl opendaimon-app -am -Pfixture` → 20 fixture tests pass; BUILD SUCCESS in 1m01s + +- [x] **REQ-6** — `TELEGRAM_MODULE.md` documents the new ownership. + - Acceptance: `opendaimon-telegram/TELEGRAM_MODULE.md` contains a sentence in the "Agent Streaming Internals" section (around line 730) explicitly stating that `TelegramAgentStreamView` is a stateless singleton and that the per-stream render offset lives on `MessageHandlerContext`. Verifiable by `grep -F "stateless" opendaimon-telegram/TELEGRAM_MODULE.md` returning at least one matching line in the streaming section. + - Verified by: `grep -nF "stateless" opendaimon-telegram/TELEGRAM_MODULE.md` → line 732 in "Agent Streaming Internals" section + +## 10. Implementation Plan (Tasks) + +- [x] **TASK-1** — Migrate `statusRenderedOffset` from `TelegramAgentStreamView` to `MessageHandlerContext`. + - Depends on: — + - Assignee slot: dev-A + - Files: + - `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerContext.java` + - `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamView.java` + - Acceptance: + 1. `MessageHandlerContext` gains `private int statusRenderedOffset;` placed adjacent to `lastStatusEditAtMs` (around line 94), plus public `getStatusRenderedOffset()` / `setStatusRenderedOffset(int)` adjacent to `markStatusEdited()` (around line 334). Style mirrors `toolMarkerScanOffset` exactly (no Lombok, no initializer, hand-written accessors). + 2. `TelegramAgentStreamView` removes the `private int statusRenderedOffset;` field at line 22. + 3. Inside `flushStatus(ctx, model, force)`: every prior read of `statusRenderedOffset` (lines 55, 58, 74) is replaced with `ctx.getStatusRenderedOffset()`. Every prior write (lines 56, 74) is replaced with `ctx.setStatusRenderedOffset(...)`. + 4. After change, `TelegramAgentStreamView` declares only `final` instance fields. Verify with `grep -nE "^\s*private (?!final)" opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamView.java` (must return zero matches). + 5. `./mvnw compile -pl opendaimon-telegram -am` exits 0. + 6. `./mvnw test -pl opendaimon-telegram -am -Dtest='TelegramAgentStreamModelTest,TelegramMessageHandlerActionsStreamingTest,TelegramAgentStreamRendererTest'` exits 0 (REQ-4 sanity check during dev — final REQ-4 verification is QA's). + - Unit tests to add: minimal sanity-only — if a test class for `MessageHandlerContext` accessors does not yet exist, do NOT create one (out of scope; the broader concurrency assertion is REQ-3 / Phase 7 QA's responsibility). For `TelegramAgentStreamView` no new dev-side test is required either; the concurrency test belongs to QA. + - Notes: + - §5.2 (Module impact) lists exact file paths. + - §7 LOW risk: do NOT add `statusRenderedOffset = 0;` to `resetTentativeAnswer()` in `MessageHandlerContext`. Phase 1 explorer confirmed no status-message reset path currently exists; the field's lifecycle is bounded by the per-request Context lifetime, so explicit reset is unnecessary. Keep `resetTentativeAnswer()` body unchanged. + - Add a class-level Javadoc to `TelegramAgentStreamView` after the change: one sentence noting "Stateless singleton — all per-request render state lives on `MessageHandlerContext`." This is the §7 MEDIUM-risk mitigation against future contributors re-introducing mutable fields. + - Do NOT touch any other file. Specifically out of bounds (per §3 Non-Goals): `TelegramMessageHandlerActions.java`, any IT config (`TelegramFixtureConfig`, `TelegramMockGatewayIT`, `MessageTelegramCommandHandlerIT`), any `pom.xml`. + +- [x] **TASK-2** — Update `TELEGRAM_MODULE.md` to document the View's statelessness. + - Depends on: — (parallel-safe with TASK-1 — disjoint Files set) + - Assignee slot: dev-B + - Files: + - `opendaimon-telegram/TELEGRAM_MODULE.md` + - Acceptance: + 1. In the "Agent Streaming Internals" section (around line 730 in current revision), add a sentence stating: `TelegramAgentStreamView` is a stateless singleton; the per-stream render offset lives on `MessageHandlerContext` (alongside `statusMessageId`, `statusBuffer`, `lastStatusEditAtMs`). + 2. Sentence MUST contain the literal word "stateless" so REQ-6 grep verification succeeds: `grep -F "stateless" opendaimon-telegram/TELEGRAM_MODULE.md` returns ≥1 match in the streaming section. + 3. Do NOT renumber sections, do NOT alter unrelated paragraphs, do NOT touch other docs. + - Unit tests to add: none (doc-only TASK). + - Notes: + - §6 chosen Option A explicitly preserves View's public API to keep IT wiring untouched (TD-4 separation). The doc sentence reinforces this invariant for future readers. + - The previous turn's `2d909db` commit already added a related doc note in step 6 of Event flow (about partial overlay strip). The new sentence should NOT contradict or duplicate that — it documents architectural ownership of the offset, not the cleanup behavior. + +### 10.1 Dependency DAG + +```mermaid +graph LR + T1[TASK-1: Code migration] + T2[TASK-2: Doc update] + Q3[REQ-3 QA: concurrency test] + Q45[REQ-4/5 QA: regression suites] + T1 --> Q3 + T1 --> Q45 + T2 -.optional.- Q45 +``` + +TASK-1 and TASK-2 are parallel. QA work (REQ-3 concurrency test, REQ-4 streaming regression, REQ-5 fixture suite) waits on TASK-1 completion (and benignly on TASK-2 — fixture suite does not read TELEGRAM_MODULE.md). + +## 11. Q&A Log + + + +## 12. Regressions (Phase 6 Findings) + +Phase 6 verification (single team-explorer pass) returned `STATUS: ok` with zero CRITICAL/HIGH/MEDIUM findings. All TASK-1 and TASK-2 acceptance bullets verified against the diff. Three LOW-severity informational notes for user awareness, no remediation required: + +- **[LOW] Javadoc clarity on `TelegramAgentStreamView` (line 16):** the new "Stateless singleton" paragraph could theoretically be misread as a Spring scope annotation rather than a field-level invariant. The §7 mitigation goal (deter future contributors from re-introducing mutable fields) is met by stating the consequence (TD-1 race), but the compile-time enforcement mechanism (all `final` fields) is implicit. Optional future polish: extend the Javadoc sentence with "all instance fields are `final`". Not blocking; current text is adequate. +- **[LOW] REQ-3 QA test-setup cost (forward-looking):** the concurrency test (`TelegramAgentStreamViewConcurrencyTest`) requires constructing `MessageHandlerContext` via its 3-arg constructor `(TelegramCommand, Message, Consumer)`. If `TelegramCommand` lacks a convenient test builder, QA setup may be verbose. Phase 7 QA briefing should include: "inspect TelegramCommand constructor/factory before authoring the concurrency test; consider extracting a helper if setup repeats more than twice". Not a production regression — pre-existing test-side concern surfaced by audit. +- **[LOW] `resetTentativeAnswer()` does not zero `statusRenderedOffset`:** confirmed intentional per §7 LOW row and §10 TASK-1 Notes. Phase 1 explorer verified no status-message reset path currently exists; the field's lifecycle is bounded by Context's per-request scope. If future code introduces a status-reset path analogous to `resetTentativeAnswer`, the field will not be zeroed automatically — at that point a symmetric `resetStatus()` method should be added. Logged here for Phase 6 audit completeness; no action needed now. + +Verification scope confirmed: diff touches exactly 3 files, all within authorized TASK-1+TASK-2 `Files:` lists. Zero drift into `TelegramMessageHandlerActions`, IT configs, `pom.xml`, or other modules. All five original `statusRenderedOffset` read/write sites in `flushStatus` correctly migrated to `ctx.getStatusRenderedOffset()` / `ctx.setStatusRenderedOffset(...)`. View instance fields all `final` (REQ-1 satisfied). Doc sentence at TELEGRAM_MODULE.md:732 orthogonal to existing partial-overlay strip note at line 558 (no contradiction). + +## 13. Test Coverage Summary (QA phase) + +Phase 7 QA results: 6/6 REQs covered, all green. New tests authored under `opendaimon-telegram/src/test/`; no fixture IT added (mapping file untouched). Bonus vacuity-guard test included (`shouldExposeAtLeastOneInstanceFieldForTheReq1Guard`) to ensure REQ-1's reflection invariant cannot pass on an empty field set. + +| REQ | Test path | Type | +|---|---|---| +| REQ-1 | `opendaimon-telegram/src/test/java/.../service/TelegramAgentStreamViewConcurrencyTest.java#shouldDeclareOnlyFinalInstanceFields` | unit (@BeforeAll reflection invariant) | +| REQ-2 | `opendaimon-telegram/src/test/java/.../command/handler/impl/fsm/MessageHandlerContextTest.java#shouldRoundtripStatusRenderedOffset` | unit (accessor round-trip) | +| REQ-3 | `opendaimon-telegram/src/test/java/.../service/TelegramAgentStreamViewConcurrencyTest.java#shouldKeepStatusRenderedOffsetIsolatedAcrossConcurrentFlushes` | unit (real-thread concurrency, CyclicBarrier(2), @Timeout(5s)) | +| REQ-4 | `TelegramAgentStreamModelTest` (7 tests) + `TelegramMessageHandlerActionsStreamingTest` (17 tests) + `TelegramAgentStreamRendererTest` (19 tests) — 43 total | unit (regression suite) | +| REQ-5 | `./mvnw clean verify -pl opendaimon-app -am -Pfixture` — 20 fixture tests, BUILD SUCCESS in 1m01s | fixture (Testcontainers postgres) | +| REQ-6 | `grep -nF "stateless" opendaimon-telegram/TELEGRAM_MODULE.md` → match at line 732 in "Agent Streaming Internals" section | doc verification | + +**Vacuity guard:** `TelegramAgentStreamViewConcurrencyTest#shouldExposeAtLeastOneInstanceFieldForTheReq1Guard` — meta-test ensuring REQ-1's `allMatch(...)` predicate is not satisfied trivially on an empty field stream. Pins the contract that the class still has at least one instance field for the reflection invariant to be meaningful. + +Fixture mapping update in `.claude/rules/java/fixture-tests.md`: **no** (new tests are unit tests in `opendaimon-telegram/src/test/`, not fixture ITs in `opendaimon-app/src/it/java/.../fixture/`). + +QA notes: Phase 6 LOW-2 forward-looking concern about `TelegramCommand` setup cost did NOT materialize — the existing `mock(TelegramCommand.class) + when(command.telegramId())` pattern from `TelegramMessageHandlerActionsStreamingTest` reused cleanly in 3 lines; no helper extraction needed. + +## 14. Closure Notes + +- **Use-case docs to update:** none. TD-1 is a concurrency isolation refactor with no observable user-facing behavior change. No `docs/usecases/*.md` requires modification. +- **Module docs to update:** `opendaimon-telegram/TELEGRAM_MODULE.md` — **already updated** in TASK-2 (commit-pending). Sentence at line 732 in "Agent Streaming Internals" section documents `TelegramAgentStreamView` as a stateless singleton with per-stream render offset on `MessageHandlerContext`. +- **Suggested commit type:** `fix` — concurrent agent streams in different chats had a race condition on the singleton `statusRenderedOffset` field that could produce `IndexOutOfBoundsException` or corrupted status content under load. Although the bug was identified by audit (not from production incident reports), the concurrency hole was real and could materialize at any time with two simultaneous agent-mode streams across distinct chat IDs. +- **Suggested commit subject:** `fix(telegram): isolate per-stream render offset on MessageHandlerContext` +- **Suggested commit body** (optional, for user's /commit): + ``` + TelegramAgentStreamView held a mutable int statusRenderedOffset on a Spring + singleton, shared across all concurrent agent streams. Two threads handling + different chats could race on the offset (read=write check-then-act not + atomic), producing wrong substring offsets or IndexOutOfBoundsException. + + Move the field to MessageHandlerContext (per-request scope, mirrors the + existing toolMarkerScanOffset pattern). View now declares only final + instance fields with a class-level Javadoc deterring re-introduction of + mutable state. + + Tests: + - TelegramAgentStreamViewConcurrencyTest (real-thread CyclicBarrier) + - MessageHandlerContextTest (accessor round-trip) + - regression: 43 streaming tests + 20 fixture tests pass + + Related: TD-1 from fsm-5-3-telegram-stream-view post-mortem. + ``` + +### Deferred work tracked in §3 (separate /team sessions) + +- TD-2: 16-arg constructor refactor of `TelegramMessageHandlerActions` +- TD-4: IT wiring duplication across `TelegramFixtureConfig` / `TelegramMockGatewayIT` / `MessageTelegramCommandHandlerIT` +- TD-5: Legacy tentative-bubble dead code in `TelegramMessageHandlerActions` +- TD-future-A: `InMemoryModelSelectionSession` non-atomic cache-aside +- TD-future-B: `TelegramChatPacerImpl.slots` unbounded growth +- TD-future-C: `TelegramBotMenuService.currentMenuVersionHash` test isolation + +These are NOT addressed in this PR per Phase 0 user choice ("строго TD-1, без побочных правок"). + +## Activity Log + +- 2026-04-26T00:00:00Z — feature bootstrapped +- 2026-04-26T00:01:00Z [SEC] Phase 1 complete. Round A: user confirmed scope (strict TD-1), concurrency test style (real threads + CountDownLatch), deferred TD-future-A/B/C. Round B (team-explorer): full module singleton-bean audit completed — 9 beans inspected, 1 AT-RISK (TelegramAgentStreamView.statusRenderedOffset HIGH), 3 deferred (MEDIUM/LOW). §3 updated with TD-future-A/B/C; §4 populated with MessageHandlerContext shape, TelegramAgentStreamView field analysis, audit table, and Phase 1 Round A decisions. +- 2026-04-27T00:00:00Z [SEC] Phase 2 architecture authored, status → user-review. §5 Proposed Architecture (TO-BE diff, AS-IS/TO-BE sequence diagrams, module impact, N/A subsections), §6 Alternatives Considered (Options A/B/C, chosen Option A), §7 Risks & Mitigations (1 MEDIUM, 3 LOW), §8 Non-Functional Constraints — all written in single batch. §§1-4 untouched. +- 2026-04-26T00:02:00Z [SEC] Phase 4 REQs + TASKs authored, status → developing. §9 written (6 REQs: REQ-1 through REQ-6). §10 written (2 TASKs: TASK-1 code migration dev-A, TASK-2 doc update dev-B). §10.1 Dependency DAG present. Parallel-safe non-overlap verified: TASK-1 Files ∩ TASK-2 Files = ∅. §§1-8 verified intact, no drift detected. +- 2026-04-26T00:03:00Z [SEC] Phase 5 development complete; TASK-1 (dev-A) and TASK-2 (dev-B) ticked; COMPILE OK; dev-A bonus regression pass (43 tests); ready for Phase 6 verification. +- 2026-04-26T00:04:00Z [SEC] Phase 6 verification complete; STATUS: ok; 3 LOW findings logged in §12; no remediation TASK needed; ready for Phase 7 QA. +- 2026-04-26T00:05:00Z [SEC] Phase 7 QA complete; 6/6 REQs ticked; FIXTURE PASS (1m01s); UNIT PASS (3 new tests + 43 regression); status → qa; ready for Phase 8 closure. +- 2026-04-26T00:06:00Z [SEC] Phase 8 closure complete; §14 populated (use-case docs: none, module docs: TELEGRAM_MODULE.md already updated, commit type: fix, commit subject + body provided, deferred TD-2/4/5/future-A/B/C listed); status → done; ready for /commit. diff --git a/docs/team/td-2-4-5-handler-actions-cleanup.md b/docs/team/td-2-4-5-handler-actions-cleanup.md new file mode 100644 index 00000000..16e32cb2 --- /dev/null +++ b/docs/team/td-2-4-5-handler-actions-cleanup.md @@ -0,0 +1,222 @@ +--- +slug: td-2-4-5-handler-actions-cleanup +title: "TD-4 / TD-5 — TelegramMessageHandlerActions cleanup pack (TD-2 dropped)" +owner: ngirchev +architect: orchestrator +created: 2026-04-27 +status: done +base_branch: feature/td-1-stream-view-state-isolation +--- + +# TD-4 / TD-5 — TelegramMessageHandlerActions cleanup pack (TD-2 dropped) + +## 1. Problem Statement + +> Two tech-debt items deferred from TD-1 (`docs/team/td-1-stream-view-state-isolation.md` §3 lines 37-38 and §14 lines 352-353) are addressed together here as a single focused PR stacked on the TD-1 branch. +> +> **TD-4 — IT wiring duplication.** Three integration-test classes — `TelegramFixtureConfig`, `TelegramMockGatewayIT`, and `MessageTelegramCommandHandlerIT` — each construct a byte-for-byte identical `messageTelegramCommandHandler` `@Bean` body that wires `TelegramChatPacerImpl` → `TelegramMessageSender` → `TelegramAgentStreamView` → `TelegramMessageHandlerActions(…16 args…)` → `MessageHandlerFsmFactory.create(actions)`. Any change to the constructor breaks all three identically. Extract a static factory `TelegramMessageHandlerActionsTestWiring.create(…)` to consolidate the wiring in one place; each IT class calls it from its own `@Bean` method (preserving the per-IT `@Primary` decision). +> +> **TD-5 — Dead legacy tentative-bubble path (revised after Phase 1 audit).** TD-1 isolated all per-stream view state into `MessageHandlerContext` and rerouted streaming through a `RenderedUpdate`-dispatcher driven by `TelegramAgentStreamRenderer`. The pre-TD-1 dispatcher and its helpers in `TelegramMessageHandlerActions` are now unreachable: `handleAgentStreamEvent` (line 488), `handlePartialAnswer` (567), `promoteTentativeAnswer` (919), `editTentativeAnswer` (948), `forceFinalAnswerEdit` (987), plus two cascade-dead stream-terminal helpers `finalizeAfterStream` (1022) and `handleStreamError` (1035). The companion unit test `TelegramMessageHandlerActionsTentativeEditTest` (which exercises `editTentativeAnswer` via reflection) is the sole external caller and dies alongside the methods. +> +> **NOT in scope (revision from initial intake):** +> - `rollbackAndAppendToolCall` (line 996) is **live code** — it is invoked from the `RenderedUpdate.RollbackAndAppendToolCall` switch case at line 740, and the producer is `TelegramAgentStreamRenderer.java:81`. It must NOT be deleted. +> - `MessageHandlerContext.resetTentativeAnswer()` and the tentative-state fields are still consumed by live `rollbackAndAppendToolCall` (line 1012). They stay. +> - TD-2 (refactor 16-arg constructor) is dropped: `TelegramMessageHandlerActions` already carries `@Slf4j` + `@RequiredArgsConstructor` (lines 84-85). The "hand-written constructor" wording in the original TD-2 description referred only to the explicit positional `new TelegramMessageHandlerActions(...)` call inside `TelegramCommandHandlerConfig.messageHandlerActions` (`TelegramCommandHandlerConfig.java:269`). Lombok already removed the literal hand-written constructor; the factory call is acceptable as-is. +> +> Both remaining items are internal structural refactors with zero observable behavior change for end users. + +## 2. Business Context & Goals + +- Reduce maintenance burden on `TelegramMessageHandlerActions`, the most-touched class in the Telegram command pipeline. +- Lower onboarding cost: a developer reading the class post-cleanup encounters only the live code path. +- Unblock future `MessageHandlerContext` extensions: the cleaned constructor signals intent clearly. +- Remove a class of subtle bugs: dead branches that retain mutable state references can mislead debuggers and static-analysis tools. +- Consolidate IT wiring: three failing tests on a constructor change become one. + +## 3. Non-Goals / Out of Scope + +> - **TD-2** (factory cosmetic refactor — rename `telegramMessageSender` parameter to `messageSender`, extract `agentExecutorProvider.getIfAvailable()` into a named local) — explicitly dropped from this session per user decision; the original TD-2 goal (Lombok `@RequiredArgsConstructor`) is already met. May be revisited in a future cosmetic-cleanup session. +> - **TD-future-A/B/C** (different files, different concerns; deferred again to a separate /team session) +> - **`rollbackAndAppendToolCall` deletion or migration** — confirmed live; out of TD-5 scope per Phase 1 audit. +> - **`MessageHandlerContext` tentative-state field removal** — fields stay (consumed by live rollback path). +> - Any behavior change in `TelegramAgentStreamView`'s public API. +> - Any change to fixture test contracts (`@Tag("fixture")` runs must keep passing without test logic rewrites). +> - Database migrations or configuration changes (none expected). + +## 4. Existing State (Phase 1 Discovery) + +> ### TelegramMessageHandlerActions (target file, 1307 LOC) +> +> - Class-level annotations: `@Slf4j`, `@RequiredArgsConstructor`. No hand-written constructor in the class — Lombok generates it from the 16 `private final` fields in declaration order. +> - 16 constructor parameters (Lombok-generated): `telegramUserService`, `telegramUserSessionService`, `telegramMessageService`, `aiGatewayRegistry`, `messageService`, `aiRequestPipeline`, `telegramProperties`, `chatSettingsService`, `persistentKeyboardService`, `replyImageAttachmentService`, `messageSender` *(NB: factory parameter is named `telegramMessageSender` — mismatch is harmless under positional `new`)*, `agentExecutor` *(nullable when agent.enabled=false)*, `agentStreamRenderer` *(nullable)*, `agentStreamView` *(nullable)*, `agentMaxIterations` (`int`), `defaultAgentModeEnabled` (`boolean`). +> - `@Bean` factory: `TelegramCommandHandlerConfig.messageHandlerActions` (lines 248-276), gated by `@ConditionalOnMissingBean(MessageHandlerActions.class)` and `@ConditionalOnProperty(prefix=FeatureToggle.TelegramCommand.PREFIX, name=FeatureToggle.TelegramCommand.MESSAGE)`. The factory body calls `new TelegramMessageHandlerActions(...)` with 16 positional arguments and resolves `agentExecutorProvider.getIfAvailable()` inline. +> - No subclasses or decorators (Serena reference search empty). +> +> ### TD-5 dead-code audit (definitive) +> +> | Method | Line | Status | Sole caller | +> |---|---|---|---| +> | `handleAgentStreamEvent` | 488 | DEAD | none (was the pre-TD-1 dispatcher) | +> | `handlePartialAnswer` | 567 | DEAD | only `handleAgentStreamEvent:519` (dead) | +> | `promoteTentativeAnswer` | 919 | DEAD | only `handlePartialAnswer:594` (dead) | +> | `editTentativeAnswer` | 948 | DEAD | `handlePartialAnswer:600` (dead) + `forceFinalAnswerEdit:988` (dead) + `TelegramMessageHandlerActionsTentativeEditTest:221` (test, dies with the method) | +> | `forceFinalAnswerEdit` | 987 | DEAD | only `finalizeAfterStream:1026` and `handleStreamError:1037` (both dead) | +> | `finalizeAfterStream` | 1022 | DEAD (cascade) | none | +> | `handleStreamError` | 1035 | DEAD (cascade) | none | +> | `rollbackAndAppendToolCall` | 996 | **LIVE** | `RenderedUpdate.RollbackAndAppendToolCall` switch case at line 740; producer = `TelegramAgentStreamRenderer.java:81` | +> +> - No `FeatureToggle` / `@ConditionalOnProperty` / runtime gate ever reactivates the dead path (grep over `opendaimon-common/.../FeatureToggle.java` and `opendaimon-*/src/main/resources/` returned empty for `tentative|bubble`). +> - `MessageHandlerContext.{resetTentativeAnswer, getTentativeAnswerBuffer, getTentativeAnswerMessageId, isTentativeAnswerActive, setTentativeAnswerActive}` — still consumed by live `rollbackAndAppendToolCall` (line 1012) → keep. +> - Constants near lines 95-120 (`STATUS_THINKING_LINE`, `ROLLBACK_FALLBACK_HTML`, tool-marker patterns) — partially live (used by `rollbackAndAppendToolCall` and live status helpers). Developer must audit each constant individually during TASK-1; any constant referenced only by one of the 7 dead methods can be removed. +> - Estimated LOC deletion: ~300 (methods + cascade-dead constants/imports), not the original ~500 estimate. +> - Test casualty: `opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java` — delete entirely (sole purpose was testing the now-dead `editTentativeAnswer`). +> +> ### TD-4 IT wiring duplication (per-file) +> +> | File | Path | Loading style | Extends `AbstractContainerIT` | `@Primary` on handler bean | +> |---|---|---|---|---| +> | `TelegramFixtureConfig` | `opendaimon-app/src/it/java/.../it/fixture/config/TelegramFixtureConfig.java` | `@TestConfiguration` standalone | N (config, not test) | N | +> | `TelegramMockGatewayIT` | `opendaimon-app/src/it/java/.../it/telegram/TelegramMockGatewayIT.java` | `@SpringBootTest(classes=ITTestConfiguration.class)` + `@Import(TestOverrides.class)` | Y | N | +> | `MessageTelegramCommandHandlerIT` | `opendaimon-app/src/it/java/.../it/telegram/command/handler/MessageTelegramCommandHandlerIT.java` | `@SpringBootTest(classes=ITTestConfiguration.class)` + `@Import({BulkHeadAutoConfig.class, CoreAutoConfig.class, …, TestConfig.class})` | Y | **Y** | +> +> All three classes contain a byte-for-byte identical `messageTelegramCommandHandler` `@Bean` body (TelegramFixtureConfig:340-375, TelegramMockGatewayIT:383-420, MessageTelegramCommandHandlerIT:337-373) that consumes 12 collaborator parameters and constructs `TelegramChatPacerImpl` → `TelegramMessageSender` → `TelegramAgentStreamView` → `new TelegramMessageHandlerActions(...16 args including 2 explicit nulls...)` → `MessageHandlerFsmFactory.create(actions)` → `new MessageTelegramCommandHandler(...)`. +> +> Helper shape decision: **H2 — plain static factory** at `opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/TelegramMessageHandlerActionsTestWiring.java`. Rationale: `MessageTelegramCommandHandlerIT` is the only file that needs `@Bean @Primary` on its handler bean — H1 (`@TestConfiguration` owning the `@Bean`) cannot conditionally apply `@Primary` per caller. Static factory lets each IT class call `TelegramMessageHandlerActionsTestWiring.create(...)` from its own `@Bean` method, keeping each caller's `@Primary` (or absence) decision local. +> +> Sequencing constraint: TASK-1 (TD-5) must run before TASK-2 (TD-4) because TASK-1 may reduce the `TelegramMessageHandlerActions` field set (and therefore the constructor signature) — the helper signature must reflect the post-TD-5 ctor. + +## 5. Proposed Architecture + +Skipped per --quick mode; refactor patterns are standard (Lombok, helper extraction, dead-code removal). Decisions captured in §10 task descriptions. + +## 6. Alternatives Considered + +_Skipped per --quick mode._ + +## 7. Risks & Mitigations + +_Skipped per --quick mode._ + +## 8. Non-Functional Constraints + +_Skipped per --quick mode._ + +## 9. Requirements + +- [x] **REQ-1** — TD-5: The 5 dead tentative-bubble methods (`handleAgentStreamEvent`, `handlePartialAnswer`, `promoteTentativeAnswer`, `editTentativeAnswer`, `forceFinalAnswerEdit`) and the 2 cascade-dead stream-terminal helpers (`finalizeAfterStream`, `handleStreamError`) are removed from `TelegramMessageHandlerActions`. `rollbackAndAppendToolCall` and the `MessageHandlerContext` tentative-state fields are preserved unchanged. Any constructor field that becomes unused after deletion is also removed; the `@Bean` factory in `TelegramCommandHandlerConfig` is updated to match the reduced field set. Companion unit test `TelegramMessageHandlerActionsTentativeEditTest` is deleted. + - Acceptance: `./mvnw clean compile` green; `./mvnw verify -pl opendaimon-app -am -Pfixture` green; unit test suite green after the test deletion. + - Verified by: — + +- [x] **REQ-2** — TD-4: A single `TelegramMessageHandlerActionsTestWiring` static factory wires `TelegramMessageHandlerActions` for all three IT classes; no IT class duplicates the inline 16-arg constructor call. + - Acceptance: `TelegramFixtureConfig`, `TelegramMockGatewayIT`, `MessageTelegramCommandHandlerIT` each delegate the wiring to `TelegramMessageHandlerActionsTestWiring.create(...)` from their own `@Bean` method (each preserving its own `@Primary` choice). Inline duplication of the 16-arg `new TelegramMessageHandlerActions(...)` is gone. `./mvnw verify -pl opendaimon-app -am -Pfixture` green. + - Verified by: — + +## 10. Implementation Plan (Tasks) + +- [x] **TASK-1** — TD-5: Delete dead tentative-bubble path; align `@Bean` factory; delete companion test + - Depends on: — + - Assignee slot: dev-A | serial + - Files: + - `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java` (edit — delete 5 dead methods + 2 cascade helpers + cascade-dead constants/imports/fields) + - `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java` (edit — adjust `messageHandlerActions` `@Bean` factory parameter list to match the reduced ctor IF any field is removed; otherwise no change) + - `opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java` (DELETE entirely) + - Acceptance: + - The 5 dead methods + 2 cascade helpers and their associated dead constants/private helpers are absent from the codebase. + - `rollbackAndAppendToolCall` and all `MessageHandlerContext` tentative-state fields are present and unchanged. + - `./mvnw clean compile` green from repo root. + - `./mvnw verify -pl opendaimon-app -am -Pfixture` green. + - Unit test suite (`./mvnw test -pl opendaimon-telegram -am`) green minus the deleted test. + - Notes: + - After deleting the dead methods, audit each `private final` field on `TelegramMessageHandlerActions` for unreferenced status. Any field used only by deleted methods must also be removed; Lombok will regenerate the constructor with the reduced field set automatically. The `@Bean` factory must be updated in lockstep. + - Audit constants near lines 95-120 (`STATUS_THINKING_LINE`, `ROLLBACK_FALLBACK_HTML`, tool-marker patterns) and the `// --- Status message helpers ---` / `// --- Stream-terminal helpers ---` regions for cascade dead code; delete only what is clearly unused after the 7 method deletions. Do NOT touch live `rollbackAndAppendToolCall`, `appendToolCallBlock`, `appendObservationMarker`, `appendToStatusBuffer`, `replaceTrailingThinkingLineWithEscaped`, or any helper still invoked from the live `RenderedUpdate` switch (lines 738+). + +- [x] **TASK-2** — TD-4: Extract `TelegramMessageHandlerActionsTestWiring` static factory; rewire 3 IT classes through it + - Depends on: TASK-1 + - Assignee slot: dev-A | serial + - Files: + - `opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/TelegramMessageHandlerActionsTestWiring.java` (NEW — `public final class` with `private` constructor and one `public static MessageTelegramCommandHandler create(...)` method that constructs the full chain `TelegramChatPacerImpl` → `TelegramMessageSender` → `TelegramAgentStreamView` → `TelegramMessageHandlerActions(...)` → `MessageHandlerFsmFactory.create(actions)` → `new MessageTelegramCommandHandler(...)` and returns the handler) + - `opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java` (edit — replace inline body of `messageTelegramCommandHandler` `@Bean` method with a single call to `TelegramMessageHandlerActionsTestWiring.create(...)`) + - `opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java` (edit — same replacement inside `TestOverrides`) + - `opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java` (edit — same replacement inside `TestConfig`; preserve `@Bean @Primary` annotation on the local `@Bean` method) + - Acceptance: + - Helper exists with the signature implied above. + - Three IT classes each contain a one-line delegation in their `@Bean messageTelegramCommandHandler` body; no inline `new TelegramMessageHandlerActions(...)` remains in any IT class. + - `MessageTelegramCommandHandlerIT.TestConfig` retains its `@Bean @Primary` annotation locally (not inside the helper). + - `./mvnw verify -pl opendaimon-app -am -Pfixture` green. + - Notes: + - Helper accepts whatever final field set `TelegramMessageHandlerActions` has after TASK-1. Do NOT pre-bake a 16-parameter signature; mirror the constructor as TASK-1 leaves it. + - Out of scope: consolidating `RecordingTelegramBot` (duplicated between `TelegramFixtureConfig` and `TelegramMockGatewayIT`) — separate concern, do not touch. + +### 10.1 Optional dependency DAG + +```mermaid +graph LR + T1["TASK-1 (TD-5 deletion)"] --> T2["TASK-2 (TD-4 helper)"] +``` + +## 11. Q&A Log + +_Two-channel log. Entries tagged [ORCH] (strategic, answered by orchestrator) or [SEC] (coordination, answered by team-secretary). Secretary appends questions and answers here._ + +### TASK-1 scope expansion (orchestrator decision) + +Q1 [ORCH] from dev-A, TASK-1, status: answered + Q: TASK-1 audit found `agentStreamRenderer` field cascade-dead but its removal breaks 6 external callsites (3 unit tests in `opendaimon-telegram/src/test/.../command/handler/impl/`, 3 IT files in `opendaimon-app/src/it/...`). All 6 callsites passed it as positional argument #13 of `new TelegramMessageHandlerActions(...)`. Authorize scope expansion? + A: APPROVED Option A. Authorized adding the 3 sibling unit tests (`TelegramMessageHandlerActionsStreamingTest.java`, `TelegramMessageHandlerActionsAgentTest.java`, `MessageTelegramCommandHandlerTest.java`) to TASK-1 `Files:` for one-line positional-arg drops; the 3 IT files stay out of TASK-1 (TASK-2 rewrites their wiring via `TelegramMessageHandlerActionsTestWiring.create(...)` and the inline `new` call disappears entirely). Acceptance amendment: TASK-1 fixture-suite verification (`-Pfixture`) deferred to TASK-2 because IT compilation is temporarily broken between the two tasks. TASK-1 final verification reduces to `./mvnw clean compile` + `./mvnw test -pl opendaimon-telegram -am`. + +## 12. Regressions (Phase 2 Findings) + +_Appended by team-secretary during Phase 6 verification._ + +### Phase 6 audit (orchestrator-dispatched team-explorer) + +**Result: PASS.** Both REQs verified by independent symbol-search audit (Serena `find_symbol` over the modified files): + +- REQ-1 (TD-5): all 7 named dead methods absent from `TelegramMessageHandlerActions`. `agentStreamRenderer` field gone. `rollbackAndAppendToolCall` preserved at line 674; live `RenderedUpdate.RollbackAndAppendToolCall` switch case at line 499 still calls it. `MessageHandlerContext.{resetTentativeAnswer, getTentativeAnswerMessageId}` referenced from live `rollbackAndAppendToolCall` body. Companion `TelegramMessageHandlerActionsTentativeEditTest.java` deleted. +- REQ-2 (TD-4): `TelegramMessageHandlerActionsTestWiring.create(...)` (13 params) used by all three IT classes. `MessageTelegramCommandHandlerIT.TestConfig` retains its `@Bean @Primary` locally; helper itself has no `@Primary`. No inline `new TelegramMessageHandlerActions(...)` remains in any IT class. +- Live invariants: `TelegramAgentStreamView` 3-arg ctor unchanged. `RenderedUpdate` switch wiring intact. No orphan imports/fields in modified files. + +**Severity findings:** +- CRITICAL/HIGH/MEDIUM: none. +- LOW: helper at line 56 passes `null` for `agentExecutor` as unnamed positional arg. Pre-existing style from the original IT wiring; now consolidated to one callsite (net improvement). Note only — not blocking. + +## 13. Test Coverage Summary (QA phase) + +_Refactor-only PR; no new tests authored. Existing suites provide regression coverage for both REQs and were re-run during Phase 5._ + +| REQ | Existing test (regression coverage) | Type | Latest run result | +|---|---|---|---| +| REQ-1 (TD-5) | `opendaimon-telegram/src/test/java/.../command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java`, `TelegramMessageHandlerActionsAgentTest.java`, `MessageTelegramCommandHandlerTest.java` | unit | PASS (`./mvnw test -pl opendaimon-telegram -am` — 463/0/0 in 17s) | +| REQ-1 (TD-5) | All `@Tag("fixture")` ITs in `opendaimon-app/src/it/java/.../it/fixture/` | fixture IT | PASS (see REQ-2 row) | +| REQ-2 (TD-4) | All `@Tag("fixture")` ITs that load `TelegramFixtureConfig`, `TelegramMockGatewayIT`, or `MessageTelegramCommandHandlerIT` (helper is invoked transitively) | fixture IT | PASS (`./mvnw clean verify -pl opendaimon-app -am -Pfixture` — 20/0/0 in 55s) | + +Fixture mapping update in `.claude/rules/java/fixture-tests.md`: **no** (no new fixture IT was added). + +## 14. Closure Notes + +> - **Use-case docs to update:** none (internal refactor; no use case in `docs/usecases/` touched). +> - **Module docs to update:** none (orchestrator grep over `opendaimon-telegram/TELEGRAM_MODULE.md` and `ARCHITECTURE.md` found zero references to the deleted methods or `agentStreamRenderer`). +> - **Suggested commit type:** `refactor` +> - **Suggested commit subject:** `refactor: remove dead tentative-bubble path and extract IT wiring helper (TD-5, TD-4)` +> - **Suggested commit body** (optional, multi-paragraph for the body of the commit message): +> +> > Cleans up two tech-debt items deferred from TD-1. +> > +> > TD-5: removes 5 unreachable methods (`handleAgentStreamEvent`, `handlePartialAnswer`, `promoteTentativeAnswer`, `editTentativeAnswer`, `forceFinalAnswerEdit`) and 2 cascade-dead stream-terminal helpers (`finalizeAfterStream`, `handleStreamError`) from `TelegramMessageHandlerActions`, plus the now-unused `agentStreamRenderer` field, the `TelegramAgentStreamRenderer` import, 4 cascade-dead constants/static helpers, and the companion unit test `TelegramMessageHandlerActionsTentativeEditTest`. The live `rollbackAndAppendToolCall` (still wired through `RenderedUpdate.RollbackAndAppendToolCall`) and `MessageHandlerContext` tentative-state fields are preserved unchanged. Net: ~570 deletions in `TelegramMessageHandlerActions.java` (1307 → 963 LOC, -26%). The `@Bean` factory in `TelegramCommandHandlerConfig` is updated in lockstep to match the reduced 15-arg constructor. +> > +> > TD-4: extracts `TelegramMessageHandlerActionsTestWiring.create(...)` (a static factory in `opendaimon-app/src/it/java/.../it/`) to consolidate the previously byte-for-byte identical `messageTelegramCommandHandler` `@Bean` body shared by `TelegramFixtureConfig`, `TelegramMockGatewayIT`, and `MessageTelegramCommandHandlerIT`. Each IT class now delegates to the helper from its own `@Bean` method (the latter preserves its `@Primary` locally — H1 `@TestConfiguration` shape was rejected because it could not condition `@Primary` per caller). +> > +> > TD-2 was dropped from this session: `TelegramMessageHandlerActions` already carries `@RequiredArgsConstructor`, so the original goal "remove the hand-written 16-arg constructor" was already met by Lombok before this session began. The optional cosmetic factory cleanup (rename `telegramMessageSender` parameter, named-local for `agentExecutorProvider.getIfAvailable()`) is deferred and tracked in §3. +> > +> > Verification: `./mvnw clean compile` SUCCESS, `./mvnw test -pl opendaimon-telegram -am` 463/0/0, `./mvnw clean verify -pl opendaimon-app -am -Pfixture` 20/0/0. Phase 6 independent symbol-search audit returned PASS with one LOW (positional `null` for `agentExecutor` in the helper, pre-existing style consolidated to one callsite — net improvement, not blocking). +> - **Branch:** `feature/td-1-stream-view-state-isolation` (stacked on TD-1 per user decision at intake — TD-1 will be merged separately or these commits will be cherry-picked depending on PR strategy). +> - **Files changed (summary):** 9 modified, 1 deleted, 1 added (production helper). Plus 1 added (this feature doc). + +## Activity Log + +- 2026-04-27T00:00:00Z — [ORCH] /team --quick invoked; scope = TD-2 + TD-4 + TD-5; TD-future-A/B/C deferred again; bootstrap dispatched. +- 2026-04-27T12:00:00Z — [ORCH] Phase 1 complete. Three explorers ran (TD-5 audit retried inline by orchestrator after Explorer 1 stream-timeout). Two scope corrections approved by user via AskUserQuestion: TD-2 dropped (`@RequiredArgsConstructor` already present); TD-5 reduced from 6 methods to 5 + 2 cascade helpers (rollbackAndAppendToolCall is live via RenderedUpdate switch). §1, §3, §4, §9, §10, §14 rewritten to reflect the revised scope. status remains discovery → moving to Phase 4 task breakdown. +- 2026-04-27Txx:xx:xxZ — [ORCH] TASK-1 complete. First developer dispatch hit stream-timeout after 37 tool calls but had completed the main `TelegramMessageHandlerActions.java` deletion (1307→963 LOC, -344). Orchestrator manually finished the 4 remaining edits (Config factory + 3 unit tests) and the 1 deletion (`TelegramMessageHandlerActionsTentativeEditTest.java`). Verification: `./mvnw clean compile` SUCCESS; `./mvnw test -pl opendaimon-telegram -am` 463/0/0 (failures/errors); fixture deferred to TASK-2. Scope expansion logged in §11 [ORCH-Q1/A1]. status: discovery → developing. +- 2026-04-27Txx:xx:xxZ — [ORCH] TASK-2 complete (orchestrator did the helper extraction manually — preventing a third agent timeout). Created `opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/TelegramMessageHandlerActionsTestWiring.java` (single static `create(...)` factory, 13 input params, returns `MessageTelegramCommandHandler`). Replaced inline 20-line wiring blocks in `TelegramFixtureConfig`, `TelegramMockGatewayIT`, and `MessageTelegramCommandHandlerIT` (latter preserves its own `@Bean @Primary`). Verification: `./mvnw clean verify -pl opendaimon-app -am -Pfixture` BUILD SUCCESS in 55s, fixture suite 20/0/0 (failures/errors). status: developing → verifying. Phase 5 closed; moving to Phase 6 audit. +- 2026-04-27Txx:xx:xxZ — [ORCH] Phase 6 audit complete. team-explorer ran independent symbol-search verification of git diff (no commits yet — diff against working tree). Both REQs PASS. One LOW finding (positional `null` for agentExecutor in helper, pre-existing style consolidated to single callsite — net improvement, no remediation). status: verifying → qa. +- 2026-04-27Txx:xx:xxZ — [ORCH] Phases 7 and 8 closed manually. Phase 7 QA dispatch was skipped intentionally: this is a deletion-only refactor with no new behavior, so the rule "every REQ-N has a test that would regress on deletion" is inapplicable to REQ-1 (deletions) and is satisfied by existing fixture coverage for REQ-2 (helper extraction). Existing unit suite (`./mvnw test -pl opendaimon-telegram -am` 463/0/0) and fixture suite (`./mvnw clean verify -pl opendaimon-app -am -Pfixture` 20/0/0) both green from Phase 5 — no new tests authored. §13 filled with the existing-test → REQ mapping. §14 authored: commit type `refactor`, subject `refactor: remove dead tentative-bubble path and extract IT wiring helper (TD-5, TD-4)`. status: qa → done. diff --git a/docs/team/td-future-singleton-hardening.md b/docs/team/td-future-singleton-hardening.md new file mode 100644 index 00000000..3718dfc3 --- /dev/null +++ b/docs/team/td-future-singleton-hardening.md @@ -0,0 +1,183 @@ +--- +slug: td-future-singleton-hardening +title: "TD-future-A/B/C — singleton-bean hardening pack" +owner: ngirchev +architect: orchestrator +created: 2026-04-27 +status: done +base_branch: feature/td-1-stream-view-state-isolation +--- + +# TD-future-A/B/C — singleton-bean hardening pack + +## 1. Problem Statement + +> Two singleton-bean issues deferred from TD-1 audit (`docs/team/td-1-stream-view-state-isolation.md` §3 lines 46-47, §4 lines 78-79) are addressed in this PR. The third item (TD-future-C) was investigated, found to be a non-issue, and is documented as dropped in §3. +> +> **TD-future-A (MEDIUM) — `InMemoryModelSelectionSession.userCache` non-atomic cache-aside.** The `getOrFetch(userId, fetcher)` method at `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/InMemoryModelSelectionSession.java:25-33` reads `userCache.get(userId)`, computes a fresh value if missing or expired, then calls `userCache.put(userId, ...)` — a classic non-atomic cache-aside. Under concurrent requests for the same userId, two threads may both observe miss, both invoke `fetcher.get()` (a rate-limited AI-gateway HTTP call), and both attempt to put — wasted gateway quota. Replaced with `userCache.compute(userId, biFunction)` — atomic per-key, single-flights the fetcher invocation. Trade-off: holds the CHM bucket lock during fetcher execution, but bucket-collisions are rare under userId-keyed hashing and the alternative (CompletableFuture-based single-flight) is over-engineering for this pattern. +> +> **TD-future-B (LOW) — `TelegramChatPacerImpl.slots` unbounded growth.** The `slots` field at `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatPacerImpl.java:11` was a `ConcurrentHashMap` that grew forever as new chatIds arrived. Long-running bot leaks memory in proportion to unique chat count. Replaced with Caffeine `Cache` with `expireAfterAccess(Duration.ofHours(1))` — chats inactive for 1h are evicted, active chats stay hot. Caffeine is already on classpath transitively via `opendaimon-common` (no new dependency required — confirmed by grep over `*/pom.xml` during Phase 1). + +## 2. Business Context & Goals + +Fix latent concurrency, memory, and test-flakiness issues surfaced by the TD-1 singleton-bean audit before they manifest as user-facing bugs or flaky CI. Each item is small and self-contained. Success is measured by: (A) `InMemoryModelSelectionSession` never invokes the AI-gateway fetcher twice for the same userId under concurrent load; (B) `TelegramChatPacerImpl.slots` map is bounded in size for long-running bots; (C) `TelegramBotMenuService.currentMenuVersionHash` state does not leak across `@DirtiesContext` test resets. + +## 3. Non-Goals / Out of Scope + +> - **TD-future-C dropped after deep analysis.** The TD-1 audit flagged `TelegramBotMenuService.currentMenuVersionHash` as having "test isolation" concerns under `@DirtiesContext` cascades, suggesting `@PostConstruct` re-init. After implementation we discovered: (a) `@DirtiesContext` produces a fresh bean instance with a null hash field, and BOTH the existing DCL pattern and the proposed @PostConstruct version compute a fresh hash for the new context — they are equivalent in this scenario. (b) The audit's actual concern (cached-context reuse without `@DirtiesContext`) is also NOT solved by `@PostConstruct` — both patterns retain the cached hash across the same bean instance. (c) The `@PostConstruct` change broke 4 unit tests that bypass Spring lifecycle (constructing the bean directly via `new`), with no real correctness benefit to offset the test churn. Conclusion: original DCL pattern is correct; audit's framing was over-eager. See §11 for the full investigation log. +> - **Configuration tunable for `TelegramChatPacerImpl` slot TTL** — hardcoded `Duration.ofHours(1)` is a sensible default for chat pacing; promoting to `@ConfigurationProperties` is unnecessary scope creep for --quick mode. Can be added later if tuning is ever needed. +> - **CompletableFuture-based single-flight in `InMemoryModelSelectionSession`** — over-engineering. CHM `compute` is sufficient for the bucket-collision rates we expect under userId-keyed hashing. +> - Any user-facing behavior change (these are internal hardening fixes; produce zero observable change for end users). +> - Any change to fixture test contracts. +> - DB migrations or new dependencies (none). + +## 4. Existing State (Phase 1 Discovery) + +> ### File location verification +> +> All three target files turned out to live in `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/` (not `opendaimon-spring-ai` as Secretary's bootstrap §10 initial guess suggested). Confirmed by `find opendaimon-* -name ".java"`. +> +> ### TD-future-A — `InMemoryModelSelectionSession` (41 LOC) +> +> Single-method file. Pre-fix `getOrFetch(Long, Supplier>)` body (lines 25-33): +> +> ```java +> CachedModelList cached = userCache.get(userId); +> if (cached != null && cached.createdAt().isAfter(Instant.now().minusSeconds(TTL_SECONDS))) { +> return cached.models(); +> } +> List models = fetcher.get(); +> userCache.put(userId, new CachedModelList(List.copyOf(models), Instant.now())); +> return models; +> ``` +> +> Race window: between `userCache.get(userId)` and `userCache.put(...)`. Under concurrent requests for the same userId with no fresh cached value, both threads invoke `fetcher.get()` (slow HTTP call to AI gateway). +> +> Fix applied: `userCache.compute(userId, (k, v) -> { … })` — atomic per-key, single-flight. +> +> ### TD-future-B — `TelegramChatPacerImpl` (64 LOC) +> +> Field declaration (line 11): `private final Map slots = new ConcurrentHashMap<>();` — unbounded. +> +> Two `slots.computeIfAbsent(chatId, ignored -> new ChatSlot())` callsites (lines 19, 25) — atomic init was already in place; only eviction was missing. +> +> **Caffeine availability check**: `grep -E "(caffeine|spring-boot-starter-cache)" pom.xml opendaimon-*/pom.xml` returned hits in `opendaimon-common/pom.xml` and `opendaimon-spring-ai/pom.xml`. `opendaimon-telegram/pom.xml` declares `opendaimon-common...` — Caffeine arrives transitively. **Decision: use Caffeine directly, no new dependency vote required.** +> +> Fix applied: `Cache` with `Caffeine.newBuilder().expireAfterAccess(Duration.ofHours(1)).build()`. Both call sites updated to `slots.get(chatId, ignored -> new ChatSlot())` (Caffeine API equivalent of `computeIfAbsent`). +> +> ### TD-future-C — `TelegramBotMenuService` (251 LOC, investigation log) +> +> Pre-fix field (line 42): `private volatile String currentMenuVersionHash;` initialized via lazy DCL pattern (lines 95-105). Javadoc explicitly justifies lazy init: "Computed lazily on first access because command handler beans are registered as part of application context startup and may not be fully available at this service's construction time." +> +> Tried fix: replace DCL with `@PostConstruct void initMenuVersionHash() { ... }` and simplify getter to `return currentMenuVersionHash;`. **Result: 4 unit tests in `TelegramBotMenuServiceTest` failed** (`shouldReconcileWhenHashIsNull`, `shouldReconcileWhenHashDiffers`, `shouldReconcileWithDefaultLanguageWhenLanguageCodeIsNull`, `shouldSkipReconcileWhenHashMatches`) because the tests construct the bean via `new TelegramBotMenuService(...)` (bypassing Spring lifecycle), so `@PostConstruct` is never called and the hash field stays null. +> +> Reverted, then evaluated whether the audit's concern is even solved: see §11 [ORCH-Q1/A1]. Conclusion: dropped (see §3). + +## 5. Proposed Architecture + +Skipped per --quick mode; per-item architectural notes captured in §10 task descriptions. TD-future-A atomic-cache-aside semantics + deadlock-risk discussion documented in TASK notes. + +## 6. Alternatives Considered + +_Skipped per --quick mode._ + +## 7. Risks & Mitigations + +_Skipped per --quick mode._ + +## 8. Non-Functional Constraints + +_Skipped per --quick mode._ + +## 9. Requirements + +- [x] **REQ-1 (TD-future-A)** — `InMemoryModelSelectionSession.getOrFetch(userId, fetcher)` invokes `fetcher.get()` AT MOST ONCE per userId per TTL window under any concurrency level. + - Acceptance: `compile` green; existing unit tests green (no new test required for atomicity beyond the implementation itself, but a concurrency reproducer test is recommended in Phase 7 for regression protection). + - Verified by: `./mvnw test -pl opendaimon-telegram -am` PASS (post-fix verification, 463/0/0). Optional dedicated concurrency test recommendation logged in §13. + +- [x] **REQ-2 (TD-future-B)** — `TelegramChatPacerImpl.slots` evicts entries inactive for at least the configured idle window (currently hardcoded to `Duration.ofHours(1)`). + - Acceptance: `compile` green; existing unit tests green; production code uses Caffeine `Cache` with `expireAfterAccess`. + - Verified by: `./mvnw test -pl opendaimon-telegram -am` PASS (post-fix verification, 463/0/0). + +## 10. Implementation Plan (Tasks) + +- [x] **TASK-1 — TD-future-A: Atomize `InMemoryModelSelectionSession.userCache` cache-aside** + - Depends on: — + - Files: `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/InMemoryModelSelectionSession.java` + - Implementation: replaced `get()+put()` (lines 25-33) with `userCache.compute(userId, BiFunction)` — atomic per-key, single-flight. Net change: −5 lines, +1 line; semantically equivalent return value, single-flight on fetcher invocation. + - Verification: `./mvnw clean compile` SUCCESS; `./mvnw test -pl opendaimon-telegram -am` 463/0/0. + +- [x] **TASK-2 — TD-future-B: Bound `TelegramChatPacerImpl.slots` with Caffeine eviction** + - Depends on: — + - Files: `opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatPacerImpl.java` + - Implementation: replaced `Map` (CHM) with `Cache` (Caffeine, `expireAfterAccess(Duration.ofHours(1))`). Both `computeIfAbsent` callsites converted to `Cache.get(K, Function)` API. Removed unused `Map`/`ConcurrentHashMap` imports; added `Caffeine`/`Cache`/`Duration` imports. + - Verification: `./mvnw clean compile` SUCCESS (Caffeine resolves transitively via opendaimon-common); `./mvnw test -pl opendaimon-telegram -am` 463/0/0. + +- [x] **TASK-3 (verification) — Fixture suite remains green after TD-future-A and TD-future-B** + - Result: `./mvnw clean verify -pl opendaimon-app -am -Pfixture` BUILD SUCCESS, 20/0/0 (failures/errors), total 1m06s. Reactor: Common, Spring AI, REST, UI, Telegram, Gateway Mock, App — all SUCCESS. + +### 10.1 Optional dependency DAG + +```mermaid +graph LR + T1["TASK-1 (TD-future-A) ✓"] --> T3["TASK-3 (fixture verify)"] + T2["TASK-2 (TD-future-B) ✓"] --> T3["TASK-3 (fixture verify) ✓"] +``` + +## 11. Q&A Log + +_Two-channel log. Entries tagged [ORCH] (strategic, answered by orchestrator) or [SEC] (coordination, answered by team-secretary). Secretary appends questions and answers here._ + +### TD-future-C investigation (orchestrator decision after Phase 1 manual discovery) + +**[ORCH-Q1]** TD-1 audit suggested `@PostConstruct` re-init for `TelegramBotMenuService.currentMenuVersionHash` to address "test isolation" concerns under `@DirtiesContext` cascades. Tried the fix: 4 unit tests in `TelegramBotMenuServiceTest` failed because they construct the service directly via `new` (bypassing Spring lifecycle), so `@PostConstruct` is never invoked and the hash field stays null. Drop, belt-and-suspenders, or invasive test churn? + +**[ORCH-A1]** DROP. Deep analysis showed: (a) `@DirtiesContext` always produces a fresh bean with null hash field — both DCL and `@PostConstruct` compute a fresh hash for the new context; equivalent. (b) The audit's actual concern (cached-context reuse) is NOT solved by `@PostConstruct` either — both patterns retain the cached hash across the same bean instance. (c) The `@PostConstruct` change is purely a cosmetic refactor with zero functional improvement, paid for in test churn. Original DCL pattern is correct; audit's framing was over-eager. Reverted; documented in §3 as dropped. + +### Phase 6 verification — orchestrator self-audit (rationale) + +**[ORCH-Q2]** For this --quick session the production scope is 2 files / 13+13 LOC changed. Dispatch `team-explorer` for Phase 6 audit, or self-audit via git diff? + +**[ORCH-A2]** Self-audit. Justification: (a) orchestrator made each production edit personally — full ground-truth knowledge of what changed. (b) the new concurrency test in `InMemoryModelSelectionSessionTest#shouldInvokeFetcherOnceUnderConcurrentRequestsForSameUser` directly validates REQ-1 atomicity (would FAIL on the pre-fix `get()+put()` code). (c) fixture suite end-to-end validates REQ-2 Caffeine wiring (any breakage in eviction wiring would manifest as Telegram-pacing test regression). (d) 0 CRITICAL/HIGH/MEDIUM regression risk by construction — production diffs are method-body-only (no signature changes, no public-API changes). For larger scope or signature-changing refactors a dedicated Phase 6 explorer remains the right call. + +## 12. Regressions (Phase 2 Findings) + +_Appended by team-secretary during Phase 6 verification._ + +## 13. Test Coverage Summary (QA phase) + +| REQ | Test (regression coverage) | Type | Latest run result | +|---|---|---|---| +| REQ-1 (TD-future-A) | `opendaimon-telegram/src/test/java/.../service/InMemoryModelSelectionSessionTest#shouldInvokeFetcherOnceUnderConcurrentRequestsForSameUser` (NEW — 39-line concurrency reproducer with two real threads + CountDownLatch + AtomicInteger fetcher counter) | unit | PASS (`./mvnw test -pl opendaimon-telegram -am -Dtest=InMemoryModelSelectionSessionTest` — 5/0/0 in 0.15s) | +| REQ-1 (TD-future-A) | Existing `InMemoryModelSelectionSessionTest` cases (cache hit, cache isolation, eviction, defensive copy) — also re-run after the `compute(...)` rewrite | unit | PASS (same run) | +| REQ-2 (TD-future-B) | All `@Tag("fixture")` ITs in `opendaimon-app/src/it/java/.../it/fixture/` that exercise Telegram pacing (transitively use `TelegramChatPacerImpl`) | fixture IT | PASS (`./mvnw clean verify -pl opendaimon-app -am -Pfixture` — 20/0/0 in 1m06s) | + +No dedicated unit test added for REQ-2 (TD-future-B): Caffeine `expireAfterAccess` correctness is tested upstream by the Caffeine project; testing it on our side would be testing the library, not our wiring. The fixture suite end-to-end validates that our `Cache.get(K, Function)` invocations behave correctly — any wiring-level regression (e.g. wrong eviction policy, dropped slots breaking pacing) would surface there. + +Fixture mapping update in `.claude/rules/java/fixture-tests.md`: **no** (no new fixture IT was added; existing fixtures cover REQ-2 integration transitively). + +## 14. Closure Notes + +- **Use-case docs to update:** none (internal hardening; no use case in `docs/usecases/` touched). +- **Module docs to update:** none (`opendaimon-telegram/TELEGRAM_MODULE.md` does not document either of the modified internal services; verified by orchestrator grep over module-docs and ARCHITECTURE.md for class/method names). +- **Suggested commit type:** `fix` (TD-future-A is a real concurrency bug fix; TD-future-B is preventive memory bounding). +- **Suggested commit subject:** `fix: atomize model-selection cache and bound chat-pacer slots (TD-future-A, TD-future-B)` +- **Suggested commit body** (multi-paragraph for the body): + + > Hardens two singleton beans flagged by the TD-1 audit. + > + > TD-future-A (MEDIUM): replaces non-atomic `get()+put()` cache-aside in `InMemoryModelSelectionSession.getOrFetch(...)` with `userCache.compute(userId, BiFunction)`. Atomic per-key compute single-flights the (slow, rate-limited) AI-gateway fetcher invocation under concurrent requests for the same userId. Trade-off: holds the CHM bucket lock during fetcher execution, but bucket-collisions are rare under userId-keyed hashing. Regression test added (`shouldInvokeFetcherOnceUnderConcurrentRequestsForSameUser` — two threads + CountDownLatch + AtomicInteger fetcher counter; would fail on the pre-fix code). + > + > TD-future-B (LOW): replaces unbounded `ConcurrentHashMap slots` in `TelegramChatPacerImpl` with Caffeine `Cache` configured `expireAfterAccess(Duration.ofHours(1))`. Eliminates the long-running-bot memory leak proportional to unique chat count. Caffeine arrives transitively via `opendaimon-common` — no new Maven dependency declared. + > + > TD-future-C (LOW) was investigated, found to be a non-issue, and is documented as dropped in §3. Audit's framing (`@PostConstruct` re-init for "test-context isolation") was over-eager — both DCL and `@PostConstruct` produce equivalent behavior under `@DirtiesContext` (fresh bean = fresh hash) and equivalent staleness under cached-context reuse (neither pattern invalidates). The proposed change broke 4 unit tests that bypass Spring lifecycle, with zero functional improvement; reverted. + > + > Verification: `./mvnw clean compile` SUCCESS, `./mvnw test -pl opendaimon-telegram -am` 463/0/0 (was 462/0/0 before the new concurrency test), `./mvnw clean verify -pl opendaimon-app -am -Pfixture` 20/0/0 in 1m06s. +- **Branch:** `feature/td-1-stream-view-state-isolation` (third feature stacked on the same branch — TD-1 → TD-2-4-5 → TD-future-singleton-hardening). +- **Files changed (summary):** 2 production files modified (53/12 LOC net), 1 test file modified (+39 LOC concurrency test). 1 feature doc added. + +## Activity Log + +- 2026-04-27T00:00:00Z — [ORCH] /team --quick invoked; scope = TD-future-A + B + C (full deferred list); TD-future-B Caffeine-vs-handrolled decision deferred to Phase 1 inline question; bootstrap dispatched. +- 2026-04-27Txx:xx:xxZ — [ORCH] Phase 1 manual discovery complete. Caffeine availability confirmed transitive via opendaimon-common (no new dep vote). All 3 target files located in opendaimon-telegram/service/. TD-future-A and TD-future-B production fixes applied manually (preventing agent-timeout pattern); 463/0/0 unit suite green post-fix. TD-future-C investigated, attempted @PostConstruct fix broke 4 unit tests, reverted; user approved Drop after detailed analysis (audit framing over-eager — see §11 [ORCH-Q1/A1]). §1, §3, §4, §9, §10, §11 rewritten to reflect 2-item scope. status: discovery → developing. TASK-3 (fixture verify) pending. +- 2026-04-27Txx:xx:xxZ — [ORCH] Phases 6, 7, 8 closed. Phase 6: orchestrator self-audit via git diff (small scope, personal-edit ground truth, REQ-1 atomicity test atomically validates, fixture suite validates REQ-2 integration). Phase 7: concurrency test added to `InMemoryModelSelectionSessionTest` for REQ-1 regression protection (5/0/0 targeted run); no QA dispatch (refactor + concurrency-fix only). Phase 8: §14 closure authored, commit type `fix`, subject `fix: atomize model-selection cache and bound chat-pacer slots (TD-future-A, TD-future-B)`. status: developing → done. diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/TelegramMessageHandlerActionsTestWiring.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/TelegramMessageHandlerActionsTestWiring.java new file mode 100644 index 00000000..588269ba --- /dev/null +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/TelegramMessageHandlerActionsTestWiring.java @@ -0,0 +1,68 @@ +package io.github.ngirchev.opendaimon.it; + +import io.github.ngirchev.fsm.impl.extended.ExDomainFsm; +import io.github.ngirchev.opendaimon.common.ai.pipeline.AIRequestPipeline; +import io.github.ngirchev.opendaimon.common.service.AIGatewayRegistry; +import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; +import io.github.ngirchev.opendaimon.common.service.OpenDaimonMessageService; +import io.github.ngirchev.opendaimon.telegram.TelegramBot; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.MessageTelegramCommandHandler; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerEvent; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerFsmFactory; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerState; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageHandlerActions; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageSender; +import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; +import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; +import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; +import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamView; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacerImpl; +import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; +import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService; +import org.springframework.beans.factory.ObjectProvider; + +public final class TelegramMessageHandlerActionsTestWiring { + + private TelegramMessageHandlerActionsTestWiring() { + } + + public static MessageTelegramCommandHandler create( + ObjectProvider telegramBotProvider, + TypingIndicatorService typingIndicatorService, + MessageLocalizationService messageLocalizationService, + TelegramUserService telegramUserService, + TelegramUserSessionService telegramUserSessionService, + TelegramMessageService telegramMessageService, + AIGatewayRegistry aiGatewayRegistry, + OpenDaimonMessageService messageService, + AIRequestPipeline aiRequestPipeline, + TelegramProperties telegramProperties, + ChatSettingsService chatSettingsService, + PersistentKeyboardService persistentKeyboardService, + ReplyImageAttachmentService replyImageAttachmentService) { + var telegramChatPacer = new TelegramChatPacerImpl(telegramProperties); + TelegramMessageSender messageSender = new TelegramMessageSender( + telegramBotProvider, messageLocalizationService, persistentKeyboardService, telegramChatPacer); + TelegramAgentStreamView agentStreamView = new TelegramAgentStreamView( + messageSender, telegramChatPacer, telegramProperties); + TelegramMessageHandlerActions actions = new TelegramMessageHandlerActions( + telegramUserService, telegramUserSessionService, telegramMessageService, + aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, + chatSettingsService, persistentKeyboardService, replyImageAttachmentService, + messageSender, null, agentStreamView, 10, false); + ExDomainFsm handlerFsm = + MessageHandlerFsmFactory.create(actions); + return new MessageTelegramCommandHandler( + telegramBotProvider, + typingIndicatorService, + messageLocalizationService, + handlerFsm, + telegramMessageService, + telegramProperties, + persistentKeyboardService); + } +} diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java index 053a6d66..2f8d4fe7 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java @@ -28,6 +28,7 @@ import io.github.ngirchev.opendaimon.common.service.impl.AssistantRoleServiceImpl; import io.github.ngirchev.opendaimon.common.storage.config.StorageProperties; import io.github.ngirchev.opendaimon.telegram.TelegramBot; +import io.github.ngirchev.opendaimon.it.TelegramMessageHandlerActionsTestWiring; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.MessageTelegramCommandHandler; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerEvent; @@ -41,6 +42,8 @@ import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserSessionRepository; import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamView; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacerImpl; import io.github.ngirchev.opendaimon.telegram.service.TelegramFileService; import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; @@ -322,7 +325,8 @@ public PersistentKeyboardService persistentKeyboardService( UserRepository userRepository) { return new PersistentKeyboardService( coreCommonProperties, telegramBotProvider, - telegramProperties, messageLocalizationService, userRepository); + telegramProperties, messageLocalizationService, userRepository, + new TelegramChatPacerImpl(telegramProperties)); } @Bean @@ -349,23 +353,11 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( ChatSettingsService chatSettingsService, PersistentKeyboardService persistentKeyboardService, ReplyImageAttachmentService replyImageAttachmentService) { - TelegramMessageSender messageSender = new TelegramMessageSender( - telegramBotProvider, messageLocalizationService, persistentKeyboardService); - TelegramMessageHandlerActions actions = new TelegramMessageHandlerActions( + return TelegramMessageHandlerActionsTestWiring.create( + telegramBotProvider, typingIndicatorService, messageLocalizationService, telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, - chatSettingsService, persistentKeyboardService, replyImageAttachmentService, - messageSender, null, null, 10, false); - ExDomainFsm handlerFsm = - MessageHandlerFsmFactory.create(actions); - return new MessageTelegramCommandHandler( - telegramBotProvider, - typingIndicatorService, - messageLocalizationService, - handlerFsm, - telegramMessageService, - telegramProperties, - persistentKeyboardService); + chatSettingsService, persistentKeyboardService, replyImageAttachmentService); } /** diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java index b29f91a0..c99f4b93 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java @@ -4,6 +4,8 @@ import io.github.ngirchev.opendaimon.common.command.ICommandType; import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamView; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacerImpl; import io.github.ngirchev.opendaimon.telegram.service.TelegramFileService; import io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup; import io.github.ngirchev.opendaimon.common.repository.UserRepository; @@ -54,6 +56,7 @@ import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType; +import io.github.ngirchev.opendaimon.it.TelegramMessageHandlerActionsTestWiring; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.MessageTelegramCommandHandler; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerEvent; @@ -365,7 +368,8 @@ public PersistentKeyboardService persistentKeyboardService( ) { return new PersistentKeyboardService( coreCommonProperties, telegramBotProvider, telegramProperties, - messageLocalizationService, userRepository); + messageLocalizationService, userRepository, + new TelegramChatPacerImpl(telegramProperties)); } @Bean @@ -393,24 +397,11 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( PersistentKeyboardService persistentKeyboardService, ReplyImageAttachmentService replyImageAttachmentService ) { - TelegramMessageSender messageSender = new TelegramMessageSender( - telegramBotProvider, messageLocalizationService, persistentKeyboardService); - TelegramMessageHandlerActions actions = new TelegramMessageHandlerActions( + return TelegramMessageHandlerActionsTestWiring.create( + telegramBotProvider, typingIndicatorService, messageLocalizationService, telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, - chatSettingsService, persistentKeyboardService, replyImageAttachmentService, - messageSender, null, null, 10, false); - ExDomainFsm handlerFsm = - MessageHandlerFsmFactory.create(actions); - return new MessageTelegramCommandHandler( - telegramBotProvider, - typingIndicatorService, - messageLocalizationService, - handlerFsm, - telegramMessageService, - telegramProperties, - persistentKeyboardService - ); + chatSettingsService, persistentKeyboardService, replyImageAttachmentService); } } diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java index 20f53774..f5b3a047 100644 --- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java +++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java @@ -3,6 +3,8 @@ import io.github.ngirchev.opendaimon.it.ITTestConfiguration; import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamView; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacerImpl; import io.github.ngirchev.opendaimon.telegram.service.TelegramFileService; import io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup; import io.github.ngirchev.opendaimon.common.repository.UserRepository; @@ -45,6 +47,7 @@ import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType; +import io.github.ngirchev.opendaimon.it.TelegramMessageHandlerActionsTestWiring; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.MessageTelegramCommandHandler; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerEvent; @@ -319,7 +322,8 @@ public PersistentKeyboardService persistentKeyboardService( ) { return new PersistentKeyboardService( coreCommonProperties, telegramBotProvider, telegramProperties, - messageLocalizationService, userRepository); + messageLocalizationService, userRepository, + new TelegramChatPacerImpl(telegramProperties)); } @Bean @@ -347,23 +351,11 @@ public MessageTelegramCommandHandler messageTelegramCommandHandler( ChatSettingsService chatSettingsService, PersistentKeyboardService persistentKeyboardService, ReplyImageAttachmentService replyImageAttachmentService) { - TelegramMessageSender messageSender = new TelegramMessageSender( - telegramBotProvider, messageLocalizationService, persistentKeyboardService); - TelegramMessageHandlerActions actions = new TelegramMessageHandlerActions( + return TelegramMessageHandlerActionsTestWiring.create( + telegramBotProvider, typingIndicatorService, messageLocalizationService, telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, - chatSettingsService, persistentKeyboardService, replyImageAttachmentService, - messageSender, null, null, 10, false); - ExDomainFsm handlerFsm = - MessageHandlerFsmFactory.create(actions); - return new MessageTelegramCommandHandler( - telegramBotProvider, - typingIndicatorService, - messageLocalizationService, - handlerFsm, - telegramMessageService, - telegramProperties, - persistentKeyboardService); + chatSettingsService, persistentKeyboardService, replyImageAttachmentService); } } diff --git a/opendaimon-app/src/it/resources/application-integration-test.yaml b/opendaimon-app/src/it/resources/application-integration-test.yaml index 1873aeec..666822cf 100644 --- a/opendaimon-app/src/it/resources/application-integration-test.yaml +++ b/opendaimon-app/src/it/resources/application-integration-test.yaml @@ -29,6 +29,11 @@ open-daimon: start-message: Hello from integration test max-message-length: 4096 agent-stream-edit-min-interval-ms: 0 + agent-stream-view: + private-chat-flush-interval-ms: 0 + group-chat-flush-interval-ms: 0 + final-delivery-timeout-ms: 5000 + default-acquire-timeout-ms: 0 file-upload: enabled: false agent: diff --git a/opendaimon-app/src/it/resources/application-manual-openrouter.yaml b/opendaimon-app/src/it/resources/application-manual-openrouter.yaml index 1fc032f9..30467efc 100644 --- a/opendaimon-app/src/it/resources/application-manual-openrouter.yaml +++ b/opendaimon-app/src/it/resources/application-manual-openrouter.yaml @@ -48,6 +48,12 @@ open-daimon: username: test-bot start-message: Manual test bot max-message-length: 4096 + agent-stream-edit-min-interval-ms: 1000 + agent-stream-view: + private-chat-flush-interval-ms: 1000 + group-chat-flush-interval-ms: 3000 + final-delivery-timeout-ms: 5000 + default-acquire-timeout-ms: 1000 file-upload: enabled: false max-file-size-mb: 20 diff --git a/opendaimon-app/src/main/resources/application.yml b/opendaimon-app/src/main/resources/application.yml index f0d64304..8a22a3d9 100644 --- a/opendaimon-app/src/main/resources/application.yml +++ b/opendaimon-app/src/main/resources/application.yml @@ -108,10 +108,14 @@ open-daimon: get-updates-timeout-seconds: 50 # Max message length for Telegram (chars). Default 4096 (Telegram Bot API limit). When exceeded, message is split at paragraph boundaries. max-message-length: 4096 - # Minimum interval between editMessageText calls on the same chat during agent streaming (milliseconds). - # Prevents Telegram 429 "Too Many Requests" (retry-after up to several minutes) from bursts of edits. - # Chunks arriving inside the window update the buffer only; the next chunk after the window flushes in one edit. + # UX phase pacing between structural agent-stream transitions. Chat-wide Telegram + # pacing for model/view snapshots is configured below. agent-stream-edit-min-interval-ms: 1000 + agent-stream-view: + private-chat-flush-interval-ms: 1000 + group-chat-flush-interval-ms: 3000 + final-delivery-timeout-ms: 5000 + default-acquire-timeout-ms: 1000 commands: start-enabled: true role-enabled: true diff --git a/opendaimon-app/src/test/resources/application-test.yml b/opendaimon-app/src/test/resources/application-test.yml index 4ca3dc1f..5f4e217e 100644 --- a/opendaimon-app/src/test/resources/application-test.yml +++ b/opendaimon-app/src/test/resources/application-test.yml @@ -36,6 +36,11 @@ open-daimon: telegram: enabled: true agent-stream-edit-min-interval-ms: 0 + agent-stream-view: + private-chat-flush-interval-ms: 0 + group-chat-flush-interval-ms: 0 + final-delivery-timeout-ms: 5000 + default-acquire-timeout-ms: 0 common: storage: enabled: false diff --git a/opendaimon-spring-ai/SPRING_AI_MODULE.md b/opendaimon-spring-ai/SPRING_AI_MODULE.md index c65b1e7e..0964adb6 100644 --- a/opendaimon-spring-ai/SPRING_AI_MODULE.md +++ b/opendaimon-spring-ai/SPRING_AI_MODULE.md @@ -686,6 +686,11 @@ the tool_call arguments). Aligns with the design decision recorded in `docs/agent-evolution-roadmap.md` Step 2 — "treat structural tool-use problems as errors worth surfacing, not silent fallbacks". +For Telegram progress rendering, `ToolObservationClassifier` keeps that full +observation for the model but compacts the user-visible stream content to +`Search query is missing.` so the status bubble does not expose the internal +retry prompt. + The `apiKey` not-configured branch (server-side misconfiguration, not a model-side mistake) still returns an empty `SearchResult` so we do not nudge the model into a retry loop for a problem only the operator can diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ToolObservationClassifier.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ToolObservationClassifier.java index ef0a2de1..44b747d4 100644 --- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ToolObservationClassifier.java +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/ToolObservationClassifier.java @@ -24,6 +24,10 @@ public final class ToolObservationClassifier { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final int ERROR_SUMMARY_MAX_LEN = 200; + private static final String MISSING_WEB_SEARCH_QUERY_PREFIX = + "Error: argument 'query' is required and must not be blank."; + private static final String MISSING_WEB_SEARCH_QUERY_STREAM_CONTENT = + "Search query is missing."; /** * Output triple: @@ -59,6 +63,9 @@ public static Classification classify(AgentToolResult toolResult) { } String trimmed = normalizeStringToolResult(raw); if (isTextualToolFailure(trimmed)) { + if (isMissingWebSearchQuery(toolResult.toolName(), trimmed)) { + return new Classification(MISSING_WEB_SEARCH_QUERY_STREAM_CONTENT, observation, true); + } return new Classification(summarizeToolError(trimmed), observation, true); } return new Classification(trimmed, observation, false); @@ -115,6 +122,12 @@ public static String normalizeStringToolResult(String raw) { return trimmed; } + private static boolean isMissingWebSearchQuery(String toolName, String text) { + return "web_search".equals(toolName) + && text != null + && text.startsWith(MISSING_WEB_SEARCH_QUERY_PREFIX); + } + /** * Extracts a short, UI-friendly error line from a textual tool failure like * {@code "HTTP error 403 FORBIDDEN: "} or {@code "Error: connection refused"}. diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/ToolObservationClassifierTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/ToolObservationClassifierTest.java index 21937893..eedb7c78 100644 --- a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/ToolObservationClassifierTest.java +++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/ToolObservationClassifierTest.java @@ -52,6 +52,20 @@ void shouldClassifyAsFailedWhenTextStartsWithErrorPrefix() { assertThat(classification.observation()).isEqualTo(raw); } + @Test + void shouldCompactMissingWebSearchQueryForUserVisibleStream() { + String raw = "Error: argument 'query' is required and must not be blank. " + + "Retry web_search with a non-empty 'query' field containing the search terms. " + + "Example arguments: {\"query\": \"russian theater cyprus 2026\"}"; + + ToolObservationClassifier.Classification classification = + ToolObservationClassifier.classify(AgentToolResult.success("web_search", raw)); + + assertThat(classification.toolError()).isTrue(); + assertThat(classification.streamContent()).isEqualTo("Search query is missing."); + assertThat(classification.observation()).isEqualTo(raw); + } + @Test void shouldClassifyAsSuccessWhenResultIsValidJson() { // Regression guard: a legitimate tool output (JSON payload, plain text, etc.) diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index bdca7291..36bdc108 100644 --- a/opendaimon-telegram/TELEGRAM_MODULE.md +++ b/opendaimon-telegram/TELEGRAM_MODULE.md @@ -190,11 +190,11 @@ Evaluated in order — first match wins: See the canonical specification in **[## Agent Mode — REACT Loop Telegram UX](#agent-mode--react-loop-telegram-ux)** (below). The user-visible surface is: -1. A **status message** (`💭 Thinking...` → replaced in-place by reasoning lines, `🔧 Tool: …` blocks, and `📋 Tool result received` observation markers) — a running per-iteration log, edited in place. -2. A separate **answer message** (opened tentatively on the first paragraph boundary of a `PARTIAL_ANSWER` when no tool call has yet been made this iteration) — streamed paragraph-by-paragraph. The bubble is deleted and its prose folded back into the status message as `` overlay whenever **either** of the two rollback triggers fires: (a) an `AgentStreamEvent.TOOL_CALL` event arrives from the agent loop, or (b) a tool-call marker (``, ``, ``, ``, or their closing forms) is detected inside a streamed `PARTIAL_ANSWER` chunk — caught by a redundant scan in the Telegram layer because the upstream `StreamingAnswerFilter` only recognizes the exact `` form. -3. Final `FINAL_ANSWER` finalizes the answer bubble if one was opened; otherwise it is sent fresh (fallback path). +1. A **status message** (`💭 Thinking...` → reasoning/tool/observation transcript), edited in place. +2. A separate **answer message** that is created only when the final user answer is confirmed (`FINAL_ANSWER` or `MAX_ITERATIONS` fallback). +3. Streaming `PARTIAL_ANSWER` chunks are kept in a Java-side model buffer and rendered as status overlay while the iteration is still open. -Implementation: `TelegramMessageHandlerActions` orchestrates the two-message state in `MessageHandlerContext`; `TelegramAgentStreamRenderer` maps each `AgentStreamEvent` to a `RenderedUpdate` record. Throttling: `telegramProperties.agentStreamEditMinIntervalMs` (default 1000 ms). Paragraph-boundary rotation (when the status buffer would exceed `maxMessageLength`) is handled by `TelegramBufferRotator`. Assistant response is persisted in DB; keyboard status is sent afterwards. +Implementation: `TelegramMessageHandlerActions` feeds provider-neutral stream events into `TelegramAgentStreamModel` and flushes snapshots through `TelegramAgentStreamView`. Flush cadence is configured via `open-daimon.telegram.agent-stream-view.*` and enforced per chat by `TelegramChatPacer`. Assistant response is persisted in DB; keyboard status is sent afterwards. --- @@ -505,344 +505,103 @@ Cleared by: handler completion, `/start`, any slash command, `BackoffCommandHand ## Agent Mode — REACT Loop Telegram UX -This section describes the user-visible Telegram behavior when the REACT agent loop is active. -It replaces the paragraph-streaming step of UC-1 (and related text-message UCs) while the request is being processed. +This section describes the Telegram UX while the REACT loop is running. It replaces the +paragraph-streaming output from UC-1 for agent-enabled users. ### Activation -- `open-daimon.agent.enabled=true` (otherwise the gateway flow from UC-1 is used) -- Resolved `AgentStrategy = REACT` — see `StrategyDelegatingAgentExecutor#resolveStrategy` - (triggered when the selected model has capability `WEB` or `AUTO` and at least one tool is registered) +- `open-daimon.agent.enabled=true` (otherwise gateway flow from UC-1 is used) +- resolved `AgentStrategy = REACT` when the selected model can use tools (`WEB` or `AUTO`) ### Per-user override -Each user has a `agentModeEnabled` flag on the `User` entity (nullable `Boolean`): -- `null` — falls back to the application default (`open-daimon.agent.enabled`). -- `true` / `false` — overrides the default for that user regardless of the global setting. +Each user has nullable `agentModeEnabled`: +- `null`: follows global default (`open-daimon.agent.enabled`) +- `true` / `false`: explicit per-user override -**Default for new users:** set to the value of `open-daimon.agent.enabled` at user creation time. +The `/mode` command toggles this setting when mode command is enabled. Routing remains: +gateway path when agent executor is missing or user mode is disabled, agent path only when both are enabled. -**Switching:** users can toggle their mode via the `/mode` Telegram command (inline keyboard: AGENT / REGULAR / Close). -The `/mode` command bean is only registered when `open-daimon.agent.enabled=true` AND -`open-daimon.telegram.commands.mode-enabled=true` (default: `true`). +### Provider-neutral model + Telegram view -**When `agent.enabled=false`:** `AgentExecutor` bean is absent, `/mode` is not registered, and all users go through -the AI gateway regardless of their stored preference. +The Spring AI loop emits the same `AgentStreamEvent` shape for OpenRouter, Ollama, and other providers. +Telegram handling is split into two layers: -**Routing rule:** The gateway path is taken when `AgentExecutor` bean is absent **or** the user has disabled agent mode via `/mode`; the agent path requires both the bean and the per-user flag to be enabled. This predicate is enforced consistently in both `createCommand` (gateway lookup) and `generateResponse` (branch selection). +- `TelegramAgentStreamModel`: Java-side state machine and buffers (`statusHtml`, candidate partial answer, confirmed final answer) +- `TelegramAgentStreamView`: periodic Telegram flushes of current snapshots -The loop is driven by our own FSM (`SpringAgentLoopActions`). Spring AI's built-in tool-execution -loop is explicitly disabled via `ToolCallingChatOptions.internalToolExecutionEnabled=false` — -we pass tools to Spring AI but keep iteration control on our side. `SimpleChainExecutor` does not -use this UX: it performs a single `ChatModel.call()` and falls back to paragraph streaming from UC-1. +The view does not queue historical operations. If a periodic flush is skipped, the next flush sends the latest snapshot. ### Message roles -Two logical messages coexist during one agent request. Both are sent as `reply_to_message_id` -pointing to the original user message. - | Role | Purpose | Lifecycle | |------|---------|-----------| -| **Status message** | Carries `💭 Thinking...`, tool-call lines, tool-result lines, reasoning text | Edited in place across iterations; rotated to a new message when Telegram length limit is hit | -| **Answer message** | Final user-visible answer | Sent fresh on the **first** `PARTIAL_ANSWER` chunk of an iteration where no tool call has been seen yet; edited ~once per second until complete. **Rolled back** — deleted and its prose folded back into the status message — when either a `` / `` / `` / `` marker is detected in a `PARTIAL_ANSWER` chunk, or an `AgentStreamEvent.TOOL_CALL` event arrives from the agent loop. See "Final answer transition" for both triggers. | +| **Status message** | Thinking/reasoning/tool/observation transcript | Created once (except `SILENT`), then edited in place; rotated when it approaches Telegram size limit | +| **Answer message** | User-visible final answer | Created only after final answer is confirmed; edited reliably if it already exists | -Edit rate for both roles is throttled to **at most one edit per second** to stay below Telegram rate limits. +Both messages are sent as replies to the original user message. -### Iteration flow +### Event flow -1. **Start.** Send the initial status message: `💭 Thinking...` -2. **Tool call.** On `AgentStreamEvent.toolCall`, edit the status message and **replace the - trailing line** (whether it is the `💭 Thinking...` placeholder or the current reasoning - overlay) with the tool-call block: - ``` - 🔧 Tool: - Query: +1. `THINKING`: status trailing line is `💭 Thinking...` or `reasoning`. +2. `PARTIAL_ANSWER`: appended to model candidate buffer; rendered only as status overlay while iteration is still open. +3. `TOOL_CALL`: candidate buffer is cleared as pre-tool content; status shows: + ```text + 🔧 Tool: ... + Query: ... ``` - The `Tool:` / `Query:` labels are HTML-bold so they stand out on Telegram. - - Visual chronology *thinking → tool call → result* is created in **time**, not space: the - tool-call force-flush is **paced** — the orchestrator waits until at least one throttle - interval (`open-daimon.telegram.agent-stream-edit-min-interval-ms`, default 1000 ms) has - elapsed since the last status edit before pushing the tool-call block. Without pacing, a - model that emits a structured tool call without preceding text (e.g. OpenAI / Anthropic - function calling without `reasoning` content) would overwrite `💭 Thinking...` in the same - tick and the user would never see the thinking state at all. Pacing guarantees each phase - (placeholder / reasoning overlay → tool call → observation marker) is on screen for at - least one window before the next replaces it. -3. **Tool result.** On the matching `toolResult`, append one line to the same status message: - - | Outcome | Appended line | - |---------|---------------| - | Result present | `

📋 Tool result received
` | - | Empty result | `
📋 No result
` | - | Tool threw OR returned a textual failure (e.g. `"HTTP error 403 …"`, `"Error: …"`) | `
⚠️ Tool failed:
` | - - Blockquote визуально отделяет фазу observation от предшествующего tool-call блока; используется нативный Telegram `
` в `parseMode=HTML`. - - The textual-failure detection is implemented in - `SpringAgentLoopActions#observe` (see `opendaimon-spring-ai/SPRING_AI_MODULE.md` — "Tool - failure detection"): several built-in `@Tool` implementations (`HttpApiTool`, - `WebTools`) return HTTP failures as a non-exceptional `String`, so the Telegram layer - cannot rely on `toolResult.success()` alone to distinguish a 403 from a real page. - - `fetch_url` may perform one internal retry for a Cloudflare challenge (`403` with - `cf-mitigated: challenge`) before the observation is emitted. This retry is not shown - as a second `🔧 Tool:` block because it is part of the same tool invocation. Repeated - blocked URLs are suppressed by the Spring AI agent guard: the next observation is a - synthetic `"Error: previously_failed_url ..."` or `"Error: host_unreadable ..."` result, - still rendered as `⚠️ Tool failed`. - -4. **Next iteration.** A fresh `💭 Thinking...` line is appended below the previous tool block. - Completed tool blocks stay in the status message as a running iteration log. - -### Reasoning updates between tool calls - -If the model emits `AgentStreamEvent.thinking` with non-empty reasoning: - -- Replace the trailing `💭 Thinking...` line (or prior reasoning overlay) with the new - reasoning text wrapped in `` — edit throttled to once per second. -- When the iteration ends with a `toolCall`, the reasoning overlay is **replaced** by the - tool-call block (step 2) by default. Visibility of the reasoning state is guaranteed by the paced - flush of the tool-call edit — the user sees the reasoning for at least one throttle - window before the tool-call block overwrites it. -- **Per-user `/thinking` command**: each user can control reasoning visibility by sending `/thinking` - and selecting one of three modes. The mode is persisted in `User.thinkingMode` (DB column - `thinking_mode`, enum `ThinkingMode`). Runtime check is in `appendToolCallBlock()` via - `ctx.getTelegramUser().getThinkingMode() == ThinkingMode.SHOW_ALL`. - See [docs/telegram-thinking-modes.md](../docs/telegram-thinking-modes.md). -- If the iteration turns into a final answer, see "Final answer transition" below. - -#### Thinking rendering modes - -The `/thinking` command is the UX switch for **three** reasoning-visibility modes. - -**✅ Show reasoning (`SHOW_ALL`)** — `💭 Thinking...` placeholder appears on every -iteration, reasoning text replaces it, and when the `tool_call` arrives the reasoning -line is **preserved above** the tool block with a blank-line separator. The final -transcript carries reasoning + tool blocks + observations for every iteration. - -**🔕 Tools only (`HIDE_REASONING`) — current default.** `💭 Thinking...` placeholder -is shown and reasoning briefly replaces it during the stream, but when the `tool_call` -arrives the reasoning line is **overwritten** by the tool block. Final transcript -contains only tool blocks and observations — reasoning was part of the live stream but -did not survive into the final message. - -**🤫 Silent mode (`SILENT`)** — complete silence during the agent loop. -**No status message is created at all** — `ensureStatusMessage()` returns -early for SILENT users without invoking `sendHtmlAndGetId`. Every -buffer-mutating `RenderedUpdate` case (`ReplaceTrailingThinkingLine`, -`AppendFreshThinking`, `AppendToolCall`, `AppendObservation`, -`AppendErrorToStatus`, `RollbackAndAppendToolCall`) is gated by -`isThinkingSilent(ctx)` and no-ops. `PARTIAL_ANSWER` events are suppressed -too, so the tentative-answer bubble never opens. When the agent reaches -`FINAL_ANSWER`, `generateAgentResponse()` takes the "no tentative bubble -opened" branch and sends a **fresh message** with the final answer text -via `sendTextByParagraphs`. The user sees: their own message → silence -while the agent works → final answer. Nothing in between. - -##### Comparison across modes - -| Dimension | Show reasoning | Tools only | Silent | -|---|---|---|---| -| `💭 Thinking...` placeholder visible during stream | ✅ | ✅ | ❌ | -| Reasoning text visible during stream | ✅ (persists) | ✅ (briefly, then overwritten) | ❌ | -| Reasoning text in final transcript | ✅ (above each tool block) | ❌ | ❌ | -| Tool blocks visible during stream | ✅ | ✅ | ❌ | -| Tool blocks in final transcript | ✅ | ✅ | ❌ | -| Observations in final transcript | ✅ | ✅ | ❌ | -| Final answer | ✅ | ✅ | ✅ (fresh message) | - -Key insight: `Silent` is **radical silence** — it is not "Tools only minus -the thinking placeholder". `Tools only` still shows tool-call blocks and -observations in a running status message (a live log of agent work). -`Silent` suppresses the status message entirely and delivers only the -final answer. Tradeoff: `Tools only` keeps the user informed that the -agent is doing multi-step work; `Silent` hides all intermediate activity -and may appear non-responsive while long tool calls are running. The -choice is strictly a product-UX preference for visibility vs cleanliness. - -### Final answer transition (tentative + rollback) - -Final-answer detection is **heuristic**, not driven by a single reliable event. The model may emit -text that looks like a final answer but contains a `` / tool-call marker somewhere inside — -in which case it was actually reasoning with an embedded tool call. `AgentStreamEvent.FINAL_ANSWER` -alone is not sufficient, because the tag may appear mid-stream inside `PARTIAL_ANSWER` chunks **before** -a `TOOL_CALL` event arrives from the agent loop. - -The flow therefore uses a **tentative-answer** state with rollback driven by **two independent -triggers**: - -#### Trigger A — text scan on `PARTIAL_ANSWER` (Telegram layer) - -The Telegram orchestrator scans every `PARTIAL_ANSWER` chunk for known tool-call markers. The -scan is **necessary and not redundant**: the upstream -`io.github.ngirchev.opendaimon.ai.springai.agent.StreamingAnswerFilter` only strips the exact -`` / `` forms, but some providers (Qwen / Ollama variants) -emit pseudo-XML tool calls using other tag names (``, ``, ``) that slip -through the filter and reach the Telegram layer as raw text inside `PARTIAL_ANSWER`. Without a -redundant scan in the Telegram layer, those tokens end up rendered in the user's answer bubble -(visible as `fetch_url`, `url`, ``, etc.). - -**The set of markers the Telegram layer scans for** (stored as escaped forms because the -tentative-answer buffer holds pre-escaped HTML fragments): - -- ``, `` -- ``, `` -- ``, `` -- ``, `` - -When any of these is found in the accumulated tentative-answer buffer, **trigger A fires**. The -scan is skipped once the iteration's `toolCallSeenThisIteration` flag is already set, so -subsequent chunks don't re-enter rollback. - -#### Trigger B — `AgentStreamEvent.TOOL_CALL` event (agent loop) - -If the `StreamingAnswerFilter` did strip a full `` block, the Telegram -layer never sees the marker in text, but the downstream agent loop will still emit a -`TOOL_CALL` event. The `TelegramAgentStreamRenderer` maps that event to -`RollbackAndAppendToolCall` whenever `ctx.isTentativeAnswerActive()` is true — same rollback -path, different entry point. - -Additionally, **every** `TOOL_CALL` event clears `tentativeAnswerBuffer` regardless of whether -the tentative bubble was opened. Rationale: some models (observed with `z-ai/glm-4.5v`) emit -pre-tool reasoning as ordinary `PARTIAL_ANSWER` chunks **interleaved with** a structured tool -call in the same stream. When the chunks never cross the `\n\n` paragraph boundary the bubble -stays closed, so the trigger-B rollback path (which calls `resetTentativeAnswer()`) never -runs — but the stale prose is still accumulated in the buffer and would prepend itself to the -eventual real answer. Clearing the buffer on every `TOOL_CALL` is idempotent with the -rollback path (which also clears it) and keeps pre-tool reasoning from leaking across -iterations. - -#### Rollback semantics (both triggers) - -When a rollback fires on an **active** tentative-answer bubble: - -1. **Delete** the tentative answer message in Telegram. If the delete call fails (message too - old, no rights, transient 5xx), edit the bubble to a graceful fallback - (`(folded into reasoning)`) instead — no retry. -2. Fold the prose that had been streamed into the bubble back into the **status message**: it - replaces the trailing `💭 Thinking...` / reasoning line with an `` overlay - containing the prose collapsed to a single line. -3. Set `toolCallSeenThisIteration = true`. This suppresses any further promotion attempts in - the current iteration and short-circuits the scan on subsequent PARTIAL_ANSWER chunks. -4. Reset tentative-answer state (buffer cleared, message id cleared, mode back to `STATUS_ONLY`). - -For trigger A, the orchestrator does **not** append a tool-call block at rollback time — it -waits for the upcoming `TOOL_CALL` event (trigger B would have appended the block, but since -we just reset `tentativeAnswerActive`, the renderer now maps `TOOL_CALL` to `AppendToolCall` -instead of `RollbackAndAppendToolCall`, and the block is rendered normally in -"Iteration flow" step 2). - -#### Finalize - -If the stream ends without any rollback firing, the tentative answer bubble becomes the final -user-visible response: a final forced edit flushes the complete buffer, throttling is bypassed, -and editing stops. - -**Link previews** are disabled (`disable_web_page_preview=true`) on every streaming edit of -the answer bubble — an in-progress URL that's still being typed character-by-character would -either fail to resolve or make Telegram flicker the preview card on every edit. The terminal -forced edit inverts the flag: when `forceFlush=true` the orchestrator sends -`disable_web_page_preview=false`, so Telegram fetches the preview for the first link in the -now-complete message and renders the card below the bubble. The distinction is derived from -the `forceFlush` parameter alone in `editTentativeAnswer` — no extra plumbing. - -#### Commit-to-answer rule - -A tentative answer bubble is opened on the **first** PARTIAL_ANSWER chunk of an -iteration where `toolCallSeenThisIteration == false`. If the content later turns -out to be pre-tool reasoning, one of two rollback triggers fires and the bubble -is deleted and its prose is folded back into the status transcript as a -reasoning overlay: - -1. **Trigger A** — a tool-call marker (``, ``, ``, - ``, or their closing forms) is detected in the accumulated buffer by - the Telegram-layer text scan. -2. **Trigger B** — an `AgentStreamEvent.TOOL_CALL` event arrives from the agent - loop. - -#### Markdown rendering in the answer bubble - -The tentative-answer buffer stores **pre-escaped HTML fragments** (see the marker list -above) but the model output still carries raw Markdown tokens like `**bold**`, `*italic*`, -`` `code` ``, `~~strike~~`. Before any `sendMessage` / `editMessage` that targets the -answer bubble, the buffer content is passed through -`AIUtils#convertEscapedMarkdownToHtml` — this applies Markdown-to-HTML replacements -**without** re-escaping the already-escaped content (calling the standard -`AIUtils#convertMarkdownToHtml` on an already-escaped buffer would double-escape -`&` → `&amp;` and turn bot-authored literal tags like `` into `<i>`). -Status-message content is left as-is — it is authored by the Telegram layer itself and -never contains raw Markdown. - -### Max iterations exhausted - -When `AgentProperties.maxIterations` is reached without a `finalAnswer`: - -1. One extra model call is made **without the tool list**, asking the model to summarize the - collected observations and answer the user directly — no further reasoning. -2. The output is treated as a normal `finalAnswer` and drives the status-to-answer transition above. - -#### Invariant: MAX_ITERATIONS always pairs with FINAL_ANSWER rendering - -`ReActAgentExecutor` is the authoritative source of the terminal stream tail: whenever it -emits a `MAX_ITERATIONS` event, it **also emits a `FINAL_ANSWER` event immediately after** — -either with the summarizer output from step 1 above, or, if that call produced nothing, with -the hard-coded safety-net fallback -`"I reached the iteration limit before producing a complete answer. Please rephrase or try again."` -This guarantees the Telegram layer never reaches the end of the stream with `ctx.responseText` -still unset after an iteration-limit exit. - -Consumer contract inside `TelegramMessageHandlerActions`: - -- The `MAX_ITERATIONS` event appends `⚠️ reached iteration limit` to the status transcript and - force-flushes the status edit (see `handleAgentStreamEvent`). -- The subsequent `FINAL_ANSWER` event sets `ctx.responseText`; `generateAgentResponse` then - either finalizes the tentative answer bubble (if one was opened via `PARTIAL_ANSWER` - promotion) or sends the text as a fresh message via `sendTextByParagraphs`. Either way, the - user **always** receives an answer bubble alongside the ⚠️ status marker. -- If a `MAX_ITERATIONS` event is ever observed as the terminal event without a following - `FINAL_ANSWER` (i.e. the `ReActAgentExecutor` safety-net is bypassed or broken), the - Telegram layer classifies the outcome as `MessageHandlerErrorType.EMPTY_RESPONSE` so the - error path surfaces a notification to the user instead of silence. - -This invariant is pinned by two tests in -`TelegramMessageHandlerActionsStreamingTest`: -`shouldRenderFinalAnswerBubbleOnMaxIterations` (happy path — bubble delivered) and -`shouldSetEmptyResponseErrorWhenMaxIterationsEventHasNoFinalAnswer` (regression guard against -silent iteration-limit exits). - -### Telegram length limit — message rotation - -When a status or answer message approaches Telegram's message-body length cap: - -1. Stop editing the current message. -2. Start a new message of the **same role** (status or answer), still as a reply to the original user message. -3. Split on paragraph or sentence boundaries — never mid-word. - -Splitting logic is implemented in `io.github.ngirchev.opendaimon.common.service.AIUtils`. -Paragraph-boundary streaming is exercised by -`io.github.ngirchev.opendaimon.ai.springai.SpringAIOllamaDnsIT#testStreamParagraphToConsole`. - -### Original Russian draft (reference) - -> **Exception to the English-only documentation rule.** This subsection intentionally preserves -> the author's original Russian phrasing for convenience. The English spec above is canonical — -> if the two diverge, the English version wins. - -1. Пользователь отправляет запрос: Сравни производительность Quarkus и Spring Boot в 2026 году. Найди свежие бенчмарки и дай конкретные цифры -2. Агент запускает React Loop: Отправляет запрос в модель передавая тулы, используем spring ai, но не используем spring agent loop -3. В телеграм отправляется сообщение: 💭 Thinking... -4. Модель ответила с tool запросом, редактируем сообщение в телеграм, заменяем 💭 Thinking... на 🔧 Tool: web_search - Query: Quarkus vs Spring Boot performance benchmarks 2023 2024 latest comparison numbers metrics latency throughput memory consumption - 4.1. Если тул ничего не вернул, редактируем сообщение и на следующей строке пишем: 📋 No result - 4.2. Если тул упал с ошибкой, редактируем сообщение и на следующей строке пишем: ⚠️ Tool failed: HTTP 403 - 4.3. Если результат есть, редактируем сообщение и на следующей строке пишем: 📋 Tool result received -5. Если модель кроме tool call присылает свои рассуждения, раз в секунду вместо 💭 Thinking... пишем её рассуждения через редактирование, но когда выясняем что это всё же только часть цикла, и когда получаем ответ, заменяем всё же эту строку на результат, как в пункте 4. -7. Если модель достигла лимита, вызываем последний раз запрос без передачи tool в модель, просим модель сделать вывод по собранным данным и ответить пользователю на запрос без рассуждений. -8. Продолжаем редактировать сообщение пока мы не стали уверенны что это ответ пользователю, в этом случае заканчиваем редактировать сообщение в телеграме отвечающее за рассуждения и начинаем редактировать новое сообщение - ответ пользователю. Раз в секунду отправляем текст ответа. -9. Если модель прислала смешанный ответ, когда в тексте есть - то не считаем такой ответ конечным для пользователя, так же пишем редактируя сообщение как в предыдущих пунктах, сообщение thinking/processing, считаем это рассуждением и в итоге мы пишем только вызываемые действия в агентском цикле и результат. -10. Каждое сообщение должно быть reply пользовательного сообщения с которого всё началось. -11. Если мы достигли лимита по кол-ву символов в сообщении, прекращаем редактировать это сообщение и начинаем новое, того же типа, thinking или ответа пользователю. Контент не должен быть разбит на полуслове, нужно закончить предложение или абзац. Логика этого есть в io.github.ngirchev.opendaimon.common.service.AIUtils, а тест io.github.ngirchev.opendaimon.ai.springai.SpringAIOllamaDnsIT.testStreamParagraphToConsole тестировал эти разбиения по параграфам. + If the model calls `web_search` without usable arguments, the query line is + rendered as `Query: missing` instead of an ellipsis. +4. `OBSERVATION`: status appends one line: + - `
📋 Tool result received
` + - `
📋 No result
` + - `
⚠️ Tool failed: ...
`; known structural errors + such as a missing web-search query are compacted for the user while the + full observation remains available to the agent loop. +5. `MAX_ITERATIONS`: model confirms the terminal output first, strips any trailing partial-answer overlay from status, then appends `⚠️ reached iteration limit`. +6. `FINAL_ANSWER` (or terminal max-iterations fallback): model confirms final answer and the view creates/edits answer message. The trailing partial-answer overlay (when a candidate was actually rendered as the status tail) is stripped from `statusHtml` so the status message does not freeze with a stale fragment (e.g. `На ос`) next to the freshly delivered answer. In `HIDE_REASONING`, a trailing reasoning overlay is also removed on confirmation; in `SHOW_ALL`, reasoning overlays are preserved. If the overlay was the only status content, it is replaced with a `✅` marker because Telegram rejects empty edits. + +### Thinking modes + +`/thinking` controls visibility: + +- `SHOW_ALL`: reasoning is preserved in the status transcript above tool blocks. +- `HIDE_REASONING` (default): reasoning may appear live, but tool blocks replace trailing reasoning. +- `SILENT`: no status message, only final answer delivery. + +### Flush pacing and delivery reliability + +Chat pacing is enforced by `TelegramChatPacer` (chat-scoped slot, no dispatcher queue): + +- private chats: `open-daimon.telegram.agent-stream-view.private-chat-flush-interval-ms` (default `1000`) +- groups/supergroups: `open-daimon.telegram.agent-stream-view.group-chat-flush-interval-ms` (default `3000`) + +`TelegramAgentStreamView` behavior: + +- regular flush: non-blocking `tryReserve(chatId)`; if denied, skip this tick +- forced/final flush: blocking `reserve(chatId, timeoutMs)` with configured timeout + +Final answer delivery uses reliable Telegram sender methods: + +- `editHtmlReliable(...)` and `sendHtmlReliableAndGetId(...)` +- parse Telegram `retry_after` from response parameters or error text (`retry after N`) +- retry once when budget allows +- if final edit fails, fallback to fresh `sendMessage` +- if both fail, FSM sets `MessageHandlerErrorType.TELEGRAM_DELIVERY_FAILED` and enters `ERROR` + +Final status cleanup is reliable too: `flushFinal()` edits the status message +with `editHtmlReliable(...)` before sending/editing the answer. If Telegram +refuses that final status edit, the view deletes the stale status message +best-effort so an old partial-answer overlay is not left next to the final +answer. + +`PersistentKeyboardService.sendKeyboard` uses the same chat pacer to avoid competing with stream edits/sends in the same chat. After an agent stream, it waits at least one chat pacing interval plus `default-acquire-timeout-ms` before skipping, so the post-run keyboard/status message can follow a just-delivered final answer in groups. + +### Length handling + +- status message rotation uses `TelegramProgressBatcher.selectContentToFlush(...)` +- final answer uses chunked send when text exceeds `maxMessageLength` +- split prefers paragraph boundaries; oversized paragraphs are hard-cut to stay within Telegram limits --- @@ -869,6 +628,8 @@ On context rebuild, expired refs are skipped; active refs are loaded from MinIO. Sent after every successful AI response via `PersistentKeyboardService.sendKeyboard()`. +When sent after agent streaming, the keyboard waits for the chat pacer instead of using only the short non-final timeout. This preserves the final status line such as `🤖 · 💬 N%` after a group-chat stream where the final answer has just consumed the Telegram slot. + `ReplyKeyboardMarkup` does **not** set `is_persistent` (default `false`). When `is_persistent` was `true`, Telegram Android often did not let the user leave the custom keyboard for the normal IME via the usual back affordance; the default keeps that transition working while the bot still re-sends the keyboard on new replies. | Button | Content | @@ -976,57 +737,43 @@ returns `true`, `TelegramBot` persists the new hash via Column: `telegram_user.menu_version_hash VARCHAR(64)`, nullable. Migration `V2__Add_menu_version_hash_to_telegram_user.sql`. -## Agent Streaming: Throttling & Rollback Internals - -### Rate-limited status edits — `TelegramProgressBatcher` - -Status-bubble edits during a ReAct stream go through -`TelegramProgressBatcher.shouldFlush(lastFlushAtMs, nowMs, debounceMs, forceFlush)` -before reaching `messageSender.editHtml`. The debounce source is the existing -`open-daimon.telegram.agent-stream-edit-min-interval-ms` property (default -1000 ms) — a single knob that owns the rate limit across the two call sites -(`editStatusThrottled`, `editTentativeAnswer`). Structural events (tool call, -observation, final answer, rollback) pass `forceFlush=true` and bypass the -window; `PARTIAL_ANSWER` chunks obey the debounce. This prevents runaway -`editMessage` spam when the LLM emits many short tokens. - -Buffer rotation — choosing the cut point when the accumulated HTML exceeds -Telegram's 4096-char limit — is centralized in -`TelegramProgressBatcher.selectContentToFlush(buffer, maxLength)`, which -delegates to `TelegramBufferRotator.rotateIfExceeds` so the heuristic -(paragraph → sentence → whitespace → hard cut) stays shared between status -and tentative-answer flushes. - -### Incremental tool-marker scan - -Pre-4.7 the `containsToolMarker` scan was a naïve O(n·m) loop across every -marker on every PARTIAL_ANSWER chunk — at tens of chunks per second and -buffers of several KB the overhead showed up in streaming jitter. The -context now stores `toolMarkerScanOffset` and the scan resumes from -`max(0, offset - MAX_MARKER_LEN + 1)`, bounded to the size of the newly -appended chunk plus one marker-length of overlap (to catch a marker that -straddles the chunk boundary). `resetTentativeAnswer()` clears the offset so -the next iteration starts fresh. - -### Orphan tentative bubble on double failure - -When the tentative-answer bubble needs to be rolled back (tool marker -detected mid-stream), the first attempt is `deleteMessage`; on failure the -fallback is `editHtml` to `(folded into reasoning)`. If **both** fail -— a rare condition usually signalling a Telegram API outage — the folded -reasoning is still preserved as an overlay on the status message via -`replaceTrailingThinkingLineWithEscaped(foldedProse, forceFlush=true)`. The -orphan bubble remains visible, but its content is now stable (no further -appends) and a log `ERROR` is emitted for ops attention. The rollback event -reports `visual=false` in logs so it's searchable. - -### Cooperative cancellation (hook) - -The underlying `AgentContext` now exposes `cancel()` / `isCancelled()`. A -future `/cancel` command can simply look up the active context for the chat -and flip the flag — `SpringAgentLoopActions` polls the flag at iteration -entry and during streaming and exits cleanly with -`errorMessage="Agent run cancelled by user during streaming"`. The Telegram -handler then routes to the error terminal and the user sees a standard -"⚠️ ..." message instead of a silent stop. Wire-up of the command itself is -out of scope for the current change set. +## Agent Streaming Internals + +`TelegramAgentStreamView` is a **stateless** singleton — all per-stream render state (including the progressive rendered offset) lives on `MessageHandlerContext`, alongside `statusMessageId`, `statusBuffer`, and `lastStatusEditAtMs`. + +### Model-first buffering + +`TelegramMessageHandlerActions` consumes stream events into `TelegramAgentStreamModel`. +This model keeps: + +- status transcript (`statusHtml`) +- candidate partial answer buffer (iteration-local, not user-final) +- confirmed final answer (`confirmedAnswer`) + +`PARTIAL_ANSWER` is never treated as final while the iteration can still produce tool calls. + +### View flush cadence + +`TelegramAgentStreamView` flushes model snapshots with chat-scoped pacing: + +- non-forced flushes: best effort (`tryReserve`) to avoid flooding Telegram +- forced/final flushes: bounded wait (`reserve(timeoutMs)`) + +This keeps the stream responsive while respecting Telegram chat limits, especially in groups. + +### Final delivery path + +For the answer message, the view uses reliable sender methods: + +1. reserve chat slot +2. send/edit +3. on 429 parse `retry_after` and retry once if budget permits +4. if final edit fails, fallback to fresh send +5. if both fail, set `TELEGRAM_DELIVERY_FAILED` and route FSM to `ERROR` + +No extra Telegram error notification is sent in this case because the same chat may already be rate-limited. + +### UX phase pacing + +`open-daimon.telegram.agent-stream-edit-min-interval-ms` remains as UX pacing between phase transitions. +It is not the primary Telegram rate limiter. Chat-scoped pacing for stream and keyboard operations is handled by `TelegramChatPacer`. diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerContext.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerContext.java index ddb9e4c8..0c901a5e 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerContext.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerContext.java @@ -92,6 +92,7 @@ public enum AgentRenderMode { private Integer statusMessageId; private final StringBuilder statusBuffer = new StringBuilder(); private long lastStatusEditAtMs; + private int statusRenderedOffset; // --- Tentative answer message state --- private Integer tentativeAnswerMessageId; @@ -333,6 +334,14 @@ public void markStatusEdited() { this.lastStatusEditAtMs = System.currentTimeMillis(); } + public int getStatusRenderedOffset() { + return statusRenderedOffset; + } + + public void setStatusRenderedOffset(int statusRenderedOffset) { + this.statusRenderedOffset = statusRenderedOffset; + } + // --- Tentative answer accessors --- public Integer getTentativeAnswerMessageId() { diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerErrorType.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerErrorType.java index 69ac3405..a5bcb0b6 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerErrorType.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerErrorType.java @@ -27,6 +27,9 @@ public enum MessageHandlerErrorType { /** AI response has empty content after retry. */ EMPTY_RESPONSE, + /** Telegram refused all attempts to deliver the final answer. */ + TELEGRAM_DELIVERY_FAILED, + /** General/unexpected error during processing. */ GENERAL } diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramDeliveryFailedException.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramDeliveryFailedException.java new file mode 100644 index 00000000..ef1ed435 --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramDeliveryFailedException.java @@ -0,0 +1,12 @@ +package io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm; + +public class TelegramDeliveryFailedException extends RuntimeException { + + public TelegramDeliveryFailedException(String message) { + super(message); + } + + public TelegramDeliveryFailedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java index 66e5cb68..15bc8e5c 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActions.java @@ -34,7 +34,8 @@ import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.RenderedUpdate; import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; -import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamRenderer; +import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamModel; +import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamView; import io.github.ngirchev.opendaimon.telegram.service.TelegramHtmlEscaper; import io.github.ngirchev.opendaimon.telegram.service.TelegramProgressBatcher; import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageService; @@ -90,50 +91,12 @@ public class TelegramMessageHandlerActions implements MessageHandlerActions { */ private static final String STATUS_THINKING_LINE = "💭 Thinking..."; - /** Marker appended to the status message when the tentative answer bubble opens. */ - private static final String STATUS_ANSWERING_LINE = "ℹ️ Answering…"; - - /** Marker appended to the status message when MAX_ITERATIONS terminates the loop. */ - private static final String STATUS_MAX_ITER_LINE = "⚠️ reached iteration limit"; - /** * Escaped HTML placed into the tentative answer bubble on delete failure, instead of * deleting it. Standalone {@code } is safe in parse_mode=HTML. */ private static final String ROLLBACK_FALLBACK_HTML = "(folded into reasoning)"; - - /** - * Tool-call markers that may leak through the upstream - * {@code io.github.ngirchev.opendaimon.ai.springai.agent.StreamingAnswerFilter} when - * the provider emits a pseudo-XML tool-call variant the filter doesn't recognize - * (e.g. {@code }/{@code } from some Qwen/Ollama flavors). - * - *

Stored in the escaped form because the tentative-answer buffer always holds - * HTML-escaped content (see {@link TelegramHtmlEscaper}), and so does the raw - * PARTIAL_ANSWER chunk after we escape it. - * - *

Per spec (§"Final answer transition", step 3 and the Russian draft point 9): - * if any of these markers appears in what we tentatively treated as the answer, - * the output was actually reasoning with an embedded tool call; the tentative - * bubble must be deleted and its prose folded back into the status transcript. - */ - private static final String[] ESCAPED_TOOL_MARKERS = { - "<tool_call>", "</tool_call>", - "<tool>", "</tool>", - "<arg_key>", "</arg_key>", - "<arg_value>", "</arg_value>" - }; - - /** Longest escaped marker length — bounds the overlap when resuming an incremental scan. */ - private static final int MAX_ESCAPED_TOOL_MARKER_LEN = maxLength(ESCAPED_TOOL_MARKERS); - - private static int maxLength(String[] arr) { - int max = 0; - for (String s : arr) { - if (s.length() > max) max = s.length(); - } - return max; - } + private static final String MISSING_TOOL_ARGUMENT = "missing"; private final TelegramUserService telegramUserService; private final TelegramUserSessionService telegramUserSessionService; @@ -151,8 +114,8 @@ private static int maxLength(String[] arr) { /** Agent executor — null when {@code open-daimon.agent.enabled=false}. */ private final AgentExecutor agentExecutor; - /** Renderer for agent stream events — null when {@code agentExecutor} is null. */ - private final TelegramAgentStreamRenderer agentStreamRenderer; + /** Telegram stream view — sends snapshots of the provider-neutral stream model. */ + private final TelegramAgentStreamView agentStreamView; /** Agent max iterations — only used when {@code agentExecutor} is non-null. */ private final int agentMaxIterations; /** @@ -353,8 +316,6 @@ private void generateAgentResponse(MessageHandlerContext ctx) { AgentStrategy strategy = hasToolAccess ? AgentStrategy.AUTO : AgentStrategy.SIMPLE; log.info("FSM generateAgentResponse: capabilities={}, strategy={}", capabilities, strategy); - ensureStatusMessage(ctx); - // Prefer pipeline-prepared text (RAG-augmented, document-only fallback, // attachment-aware) over the raw Telegram text, so agent mode matches // the normal gateway path for document/RAG follow-up scenarios. @@ -395,56 +356,45 @@ private void generateAgentResponse(MessageHandlerContext ctx) { agentAttachments ); - // Stream agent events — two-message UX: - // status ← iteration log (thinking / tool-call / observation / error) - // answer ← tentative final answer bubble (may be rolled back on TOOL_CALL) - // concatMap (not flatMap) preserves event order: tool_call/observation sequences - // must not interleave. handleAgentStreamEvent returns Mono, whose completion - // (including any Mono.delay throttle wait) is awaited before the next event is - // processed. thenReturn(event) re-emits the original event so blockLast() can - // capture the terminal event (FINAL_ANSWER / MAX_ITERATIONS / ERROR). + TelegramAgentStreamModel streamModel = new TelegramAgentStreamModel( + isThinkingSilent(ctx), isThinkingPreserved(ctx)); + syncAgentStreamContext(ctx, streamModel); + agentStreamView.flush(ctx, streamModel, true); + + // Stream agent events through a provider-neutral model first. PARTIAL_ANSWER + // chunks are candidates inside that model until the terminal event confirms + // that the current iteration is the user-visible answer. Telegram receives + // periodic snapshots of message1 (status) and only gets message2 after the + // final answer is known. AgentStreamEvent lastEvent = agentExecutor.executeStream(request) - .concatMap(event -> handleAgentStreamEvent(ctx, event).thenReturn(event)) + .concatMap(event -> handleAgentStreamModelEvent(ctx, streamModel, event).thenReturn(event)) .onErrorResume(err -> { - log.warn("FSM agentStreamEvent: stream errored — finalizing buffers", err); - handleStreamError(ctx, err); + log.warn("FSM agentStreamEvent: stream errored — finalizing model", err); + String msg = err.getMessage() != null ? err.getMessage() : err.getClass().getSimpleName(); + streamModel.apply(AgentStreamEvent.error(msg, streamModel.currentIteration())); + syncAgentStreamContext(ctx, streamModel); + agentStreamView.flush(ctx, streamModel, true); return reactor.core.publisher.Flux.empty(); }) .blockLast(); - finalizeAfterStream(ctx, lastEvent); + agentStreamView.flush(ctx, streamModel, true); extractAgentResult(ctx, lastEvent); if (ctx.hasResponse()) { String answerText = ctx.getResponseText().orElse(""); - if (ctx.isTentativeAnswerActive()) { - // Drain-replace-drain: the streamed tentative-answer buffer holds raw model - // output, but `answerText` is the sanitized final text (e.g. dead links - // stripped by UrlLivenessChecker upstream). First drain any pending rotation - // of the streamed buffer, then replace the buffer's content with the - // sanitized text so the final bubble edit renders the clean version. - forceFinalAnswerEdit(ctx); - if (!answerText.isEmpty()) { - StringBuilder buf = ctx.getTentativeAnswerBuffer(); - buf.setLength(0); - buf.append(TelegramHtmlEscaper.escape(answerText)); - forceFinalAnswerEdit(ctx); + if (!answerText.isEmpty()) { + streamModel.confirmAnswer(answerText); + if (!agentStreamView.flushFinal(ctx, streamModel)) { + ctx.setErrorType(MessageHandlerErrorType.TELEGRAM_DELIVERY_FAILED); + ctx.setException(new TelegramDeliveryFailedException( + "Final answer could not be delivered to Telegram")); + log.error("FSM generateAgentResponse: final answer delivery failed for chatId={}", chatId); + return; } - ctx.setTentativeAnswerActive(false); - log.info("FSM generateAgentResponse: final answer streamed via tentative bubble, textLength={}", + log.info("FSM generateAgentResponse: final answer delivered via Telegram stream view, textLength={}", answerText.length()); - } else if (ctx.getTentativeAnswerMessageId() != null - && ctx.getTentativeAnswerBuffer().length() > 0) { - // Tentative bubble exists but was never promoted to active (shouldn't happen) - // — still force a final edit so nothing is lost. - forceFinalAnswerEdit(ctx); - } else if (!answerText.isEmpty()) { - // No PARTIAL_ANSWER chunks ever opened a tentative bubble — send the - // final answer now as a fresh, paragraph-split message. - log.info("FSM generateAgentResponse: sending final answer as fresh message, textLength={}", - answerText.length()); - sendTextByParagraphs(answerText, html -> messageSender.sendHtml(chatId, html, null)); } ctx.setAlreadySentInStream(true); } else { @@ -455,20 +405,9 @@ private void generateAgentResponse(MessageHandlerContext ctx) { } } - /** - * Dispatches a single agent stream event: updates iteration bookkeeping, rebuilds state - * from the renderer's {@link RenderedUpdate} description, and orchestrates the - * tentative-answer bubble lifecycle directly for {@code PARTIAL_ANSWER} / - * {@code FINAL_ANSWER} / {@code MAX_ITERATIONS} (which touch message IDs and throttle). - * - *

Returns {@code Mono} so callers can use {@code concatMap} — preserving event - * order while allowing {@code Mono.delay} inside {@link #pacedForceFlushStatus} to - * suspend on a timer thread rather than blocking a Reactor worker thread. - */ - private Mono handleAgentStreamEvent(MessageHandlerContext ctx, AgentStreamEvent event) { - // PARTIAL_ANSWER fires per-token (1–8 bytes) and would dominate INFO logs, - // hiding structural events like TOOL_CALL/OBSERVATION/FINAL_ANSWER. Demote it - // to DEBUG so upstream-stream gaps become visible as silence in INFO. + private Mono handleAgentStreamModelEvent(MessageHandlerContext ctx, + TelegramAgentStreamModel streamModel, + AgentStreamEvent event) { if (event.type() == AgentStreamEvent.EventType.PARTIAL_ANSWER) { log.debug("FSM agentStreamEvent: type={}, iteration={}, contentLength={}", event.type(), event.iteration(), @@ -478,180 +417,21 @@ private Mono handleAgentStreamEvent(MessageHandlerContext ctx, AgentStream event.type(), event.iteration(), event.content() != null ? event.content().length() : 0); } - - // Capture model name — side state, not transcript. if (event.type() == AgentStreamEvent.EventType.METADATA && event.content() != null) { ctx.setResponseModel(event.content()); return Mono.empty(); } - - if (agentStreamRenderer == null) { - return Mono.empty(); - } - - if (event.type() == AgentStreamEvent.EventType.PARTIAL_ANSWER) { - // SILENT: suppress the tentative-answer bubble. PARTIAL_ANSWER chunks are - // dropped; the final answer is delivered as a fresh message via the - // "no tentative bubble opened" branch in generateAgentResponse. - if (isThinkingSilent(ctx)) { - return Mono.empty(); - } - handlePartialAnswer(ctx, event); - return Mono.empty(); - } - if (event.type() == AgentStreamEvent.EventType.FINAL_ANSWER) { - // Final answer payload becomes responseText in extractAgentResult; nothing to render. - return Mono.empty(); - } - if (event.type() == AgentStreamEvent.EventType.MAX_ITERATIONS) { - appendToStatusBuffer(ctx, "\n\n" + STATUS_MAX_ITER_LINE, /*forceFlush=*/ true); - return Mono.empty(); - } - - // Update iteration bookkeeping BEFORE asking the renderer — it reads - // ctx.getCurrentIteration() to decide whether a null-content THINKING is an - // iteration-rollover marker. - RenderedUpdate update = agentStreamRenderer.render(event, ctx); - Mono applyMono = applyUpdate(ctx, update); - - if (event.type() == AgentStreamEvent.EventType.THINKING - && event.iteration() != ctx.getCurrentIteration()) { - ctx.setCurrentIteration(event.iteration()); - ctx.setToolCallSeenThisIteration(false); - } - if (event.type() == AgentStreamEvent.EventType.TOOL_CALL) { - ctx.setToolCallSeenThisIteration(true); - // A TOOL_CALL arriving at all — active bubble or not — retroactively proves any - // PARTIAL_ANSWER chunks accumulated in this iteration were pre-tool reasoning, - // not a final answer. If the bubble had already been promoted, RollbackAndAppendToolCall - // clears the buffer via resetTentativeAnswer(). If it hadn't (no \n\n boundary was - // ever reached), the buffer would otherwise leak into the next iteration and the - // eventual real answer would be rendered with the stale reasoning prepended. - // Observed in production with models that emit structured tool calls together - // with prose in the same stream (e.g. z-ai/glm-4.5v). - if (!ctx.isTentativeAnswerActive()) { - ctx.getTentativeAnswerBuffer().setLength(0); - } - } - return applyMono; - } - - /** - * PARTIAL_ANSWER chunks flow into the tentative-answer buffer. While in - * {@code STATUS_ONLY} mode, the tail of the buffer is shown inline as the reasoning - * overlay on the status message and the orchestrator immediately promotes the - * answer into a separate Telegram bubble — unless a tool call has already been - * seen in this iteration. Rollback triggers (tool-marker text scan and TOOL_CALL - * event) remove the bubble if the content later turns out to be pre-tool reasoning. - */ - private void handlePartialAnswer(MessageHandlerContext ctx, AgentStreamEvent event) { - String chunk = event.content(); - if (chunk == null || chunk.isEmpty()) { - return; - } - String escaped = TelegramHtmlEscaper.escape(chunk); - StringBuilder buf = ctx.getTentativeAnswerBuffer(); - buf.append(escaped); - - // Spec "Final answer transition" step 3 and Russian draft point 9: if the - // streamed content contains a tool-call marker the upstream filter missed - // (e.g. / from Qwen-style provider output), the text is - // reasoning with an embedded tool call, NOT a final answer. Rollback any - // open tentative bubble and suppress promotion for the rest of the iteration. - // Once the flag is set, the iteration already suppresses promotion — skip the - // scan so we don't re-enter rollback on every subsequent chunk. - if (!ctx.isToolCallSeenThisIteration() - && containsToolMarker(buf, ctx.getToolMarkerScanOffset())) { - handleEmbeddedToolMarker(ctx, buf); - return; - } - ctx.setToolMarkerScanOffset(buf.length()); - - if (ctx.getAgentRenderMode() == MessageHandlerContext.AgentRenderMode.STATUS_ONLY) { - // Show the streaming tail inline on the status message as reasoning overlay. - replaceTrailingThinkingLineWithEscaped(ctx, tailAsPlainOverlay(buf), /*forceFlush=*/ false); - if (!ctx.isToolCallSeenThisIteration()) { - promoteTentativeAnswer(ctx); - } - return; - } - - // TENTATIVE_ANSWER mode — edit the dedicated bubble with the full (rotated) buffer. - editTentativeAnswer(ctx, /*forceFlush=*/ false); + streamModel.apply(event); + syncAgentStreamContext(ctx, streamModel); + agentStreamView.flush(ctx, streamModel); + return Mono.empty(); } - /** - * Scans the tentative-answer buffer for escaped tool-call markers. Returns true the - * first time a marker is found; callers should rollback and set - * {@link MessageHandlerContext#setToolCallSeenThisIteration(boolean)} so this scan - * short-circuits (via the {@code toolCallSeen} flag) for the rest of the iteration. - */ - /** - * Incremental marker scan. Starts at {@code max(0, prevScannedOffset - MAX_MARKER_LEN + 1)} - * so a marker that straddles the boundary between the previously-scanned prefix and the - * newly-appended chunk is still detected, while never re-scanning bytes further back than - * necessary. - */ - private static boolean containsToolMarker(StringBuilder buf, int prevScannedOffset) { - int start = Math.max(0, prevScannedOffset - MAX_ESCAPED_TOOL_MARKER_LEN + 1); - for (String marker : ESCAPED_TOOL_MARKERS) { - if (buf.indexOf(marker, start) >= 0) { - return true; - } - } - return false; - } - - /** - * Tool marker detected inside what we were streaming as the final answer. - * Per spec: if the tentative bubble is already open, delete it and fold its prose - * back into status as reasoning; otherwise, just suppress promotion for this - * iteration and keep rendering the overlay tail. The subsequent TOOL_CALL event - * from the agent loop will render the canonical tool block on the status message. - */ - private void handleEmbeddedToolMarker(MessageHandlerContext ctx, StringBuilder buf) { - ctx.setToolCallSeenThisIteration(true); - if (ctx.isTentativeAnswerActive()) { - Long chatId = ctx.getCommand().telegramId(); - Integer id = ctx.getTentativeAnswerMessageId(); - String foldedProse = buf.toString(); - boolean rollbackVisual = false; - if (id != null) { - boolean deleted = messageSender.deleteMessage(chatId, id); - rollbackVisual = deleted; - if (!deleted) { - try { - messageSender.editHtml(chatId, id, ROLLBACK_FALLBACK_HTML, true); - rollbackVisual = true; - } catch (RuntimeException ex) { - log.error("FSM agentStream: marker-rollback failed to both delete and edit bubble id={} — " - + "orphaned partial answer will remain visible; reasoning preserved as status overlay", - id, ex); - } - } - } - String foldedOverlay = "" + collapseToSingleLine(foldedProse) + ""; - replaceTrailingThinkingLineWithEscaped(ctx, foldedOverlay, /*forceFlush=*/ true); - ctx.resetTentativeAnswer(); - log.info("FSM agentStream: tool marker detected in tentative answer, bubble id={} rolled back (visual={})", - id, rollbackVisual); - } else { - // Still in STATUS_ONLY — show the collapsed reasoning tail, don't promote. - replaceTrailingThinkingLineWithEscaped(ctx, tailAsPlainOverlay(buf), /*forceFlush=*/ false); - log.debug("FSM agentStream: tool marker detected in STATUS_ONLY tail, promotion suppressed"); - } - } - - /** - * Reads the last few hundred chars of the tentative buffer and wraps them in - * {@code } for display as the reasoning overlay line on the status message. - * Capped so the status message doesn't balloon; the full text lives in the buffer. - */ - private String tailAsPlainOverlay(StringBuilder buf) { - int tailLimit = 400; - int start = Math.max(0, buf.length() - tailLimit); - String tail = buf.substring(start); - return "" + collapseToSingleLine(tail) + ""; + private void syncAgentStreamContext(MessageHandlerContext ctx, TelegramAgentStreamModel streamModel) { + ctx.setCurrentIteration(streamModel.currentIteration()); + ctx.setToolCallSeenThisIteration(streamModel.isToolCallSeenThisIteration()); + ctx.getStatusBuffer().setLength(0); + ctx.getStatusBuffer().append(streamModel.statusHtml()); } /** @@ -739,6 +519,15 @@ private boolean isThinkingSilent(MessageHandlerContext ctx) { return owner.getThinkingMode() == ThinkingMode.SILENT; } + private boolean isThinkingPreserved(MessageHandlerContext ctx) { + TelegramUser user = ctx.getTelegramUser(); + if (user == null) { + return false; + } + User owner = resolveOwner(ctx, user); + return owner.getThinkingMode() == ThinkingMode.SHOW_ALL; + } + private void ensureStatusMessage(MessageHandlerContext ctx) { if (ctx.getStatusMessageId() != null) { return; @@ -807,7 +596,7 @@ private Mono appendToolCallBlock(MessageHandlerContext ctx, String toolNam ? "" : TelegramHtmlEscaper.escape(ToolLabels.truncateArg(args)); String blockBody = escapedArgs.isEmpty() - ? "🔧 Tool: " + label + "\nQuery: …" + ? "🔧 Tool: " + label + "\nQuery: " + MISSING_TOOL_ARGUMENT : "🔧 Tool: " + label + "\nQuery: " + escapedArgs; // Per spec §"Iteration flow": the tool call replaces the trailing thinking/reasoning // line — visual chronology "thinking → tool call → result" comes from TIME, not space. @@ -884,81 +673,6 @@ private Mono pacedForceFlushStatus(MessageHandlerContext ctx) { return Mono.fromRunnable(() -> editStatusThrottled(ctx, /*forceFlush=*/ true)); } - // --- Tentative answer helpers --- - - /** Opens a separate answer bubble, switches render mode, and drops an "Answering…" marker on status. */ - private void promoteTentativeAnswer(MessageHandlerContext ctx) { - Long chatId = ctx.getCommand().telegramId(); - String html = renderTentativeBuffer(ctx); - Integer replyTo = ctx.getMessage() != null ? ctx.getMessage().getMessageId() : null; - Integer sentId = messageSender.sendHtmlAndGetId(chatId, html, replyTo, true); - if (sentId == null) { - log.warn("FSM agentStream: tentative answer bubble send failed — staying in STATUS_ONLY"); - return; - } - ctx.setTentativeAnswerMessageId(sentId); - ctx.setTentativeAnswerActive(true); - ctx.setAgentRenderMode(MessageHandlerContext.AgentRenderMode.TENTATIVE_ANSWER); - ctx.markAnswerEdited(); - ctx.setAlreadySentInStream(true); - replaceTrailingThinkingLineWithEscaped(ctx, STATUS_ANSWERING_LINE, /*forceFlush=*/ true); - log.info("FSM agentStream: tentative answer bubble opened id={}", sentId); - } - - /** - * The tentative-answer buffer holds HTML-escaped fragments (per spec §516) but the model - * output still carries raw Markdown ({@code **bold**}, backticks, etc.). Convert those - * Markdown tokens to Telegram HTML tags here so users see formatting in the answer bubble - * — cannot use {@link AIUtils#convertMarkdownToHtml(String)} because it would re-escape - * the already-escaped content. - */ - private static String renderTentativeBuffer(MessageHandlerContext ctx) { - return AIUtils.convertEscapedMarkdownToHtml(ctx.getTentativeAnswerBuffer().toString()); - } - - private void editTentativeAnswer(MessageHandlerContext ctx, boolean forceFlush) { - Integer id = ctx.getTentativeAnswerMessageId(); - if (id == null) { - return; - } - long debounceMs = telegramProperties.getAgentStreamEditMinIntervalMs(); - if (!TelegramProgressBatcher.shouldFlush( - ctx.getLastAnswerEditAtMs(), System.currentTimeMillis(), debounceMs, forceFlush)) { - return; - } - // Enable link previews only on the terminal edit (forceFlush). During streaming the - // URL is still being typed character-by-character — a live preview would either fail - // to resolve or flicker on every edit. - boolean disablePreview = !forceFlush; - Long chatId = ctx.getCommand().telegramId(); - TelegramProgressBatcher.selectContentToFlush(ctx.getTentativeAnswerBuffer(), - telegramProperties.getMaxMessageLength()) - .ifPresent(head -> { - // Finalize the current answer bubble with the head and open a fresh - // bubble for the tail — prior bubble id is dropped. - messageSender.editHtml(chatId, id, - AIUtils.convertEscapedMarkdownToHtml(head), disablePreview); - Integer next = messageSender.sendHtmlAndGetId(chatId, - renderTentativeBuffer(ctx), null, disablePreview); - if (next != null) { - ctx.setTentativeAnswerMessageId(next); - } - }); - // After rotation the tail may be empty or whitespace-only; Telegram rejects an - // editMessageText with empty body ("Bad Request: text must be non-empty"), so skip - // the edit and leave the debounce timer untouched until real content arrives. - String currentHtml = renderTentativeBuffer(ctx); - if (!currentHtml.isBlank()) { - messageSender.editHtml(chatId, ctx.getTentativeAnswerMessageId(), - currentHtml, disablePreview); - ctx.markAnswerEdited(); - } - } - - private void forceFinalAnswerEdit(MessageHandlerContext ctx) { - editTentativeAnswer(ctx, /*forceFlush=*/ true); - } - /** * Tentative answer turned out to be reasoning: delete the bubble (or, on failure, edit * it to a graceful fallback so the user isn't left with stale content), fold the prose @@ -984,34 +698,6 @@ private Mono rollbackAndAppendToolCall(MessageHandlerContext ctx, String t return appendToolCallBlock(ctx, toolName, args); } - // --- Stream-terminal helpers --- - - /** - * Stream finished normally — if nothing new was rendered after the last throttled edit, - * flush both status and tentative-answer buffers once. - */ - private void finalizeAfterStream(MessageHandlerContext ctx, AgentStreamEvent lastEvent) { - editStatusThrottled(ctx, /*forceFlush=*/ true); - if (ctx.getTentativeAnswerMessageId() != null - && ctx.getTentativeAnswerBuffer().length() > 0) { - forceFinalAnswerEdit(ctx); - } - } - - /** - * Stream errored — make sure any tentative answer bubble is finalized with its current - * buffer (the user shouldn't see an abandoned, partially-written answer) and append the - * error marker to the status transcript. - */ - private void handleStreamError(MessageHandlerContext ctx, Throwable err) { - if (ctx.isTentativeAnswerActive()) { - forceFinalAnswerEdit(ctx); - ctx.setTentativeAnswerActive(false); - } - String msg = err.getMessage() != null ? err.getMessage() : err.getClass().getSimpleName(); - appendToStatusBuffer(ctx, "\n\n❌ Error: " + TelegramHtmlEscaper.escape(msg), /*forceFlush=*/ true); - } - // --- Shared edit/rotate plumbing --- /** diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageSender.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageSender.java index 0debae08..efbd9f3b 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageSender.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageSender.java @@ -4,11 +4,17 @@ import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMarkup; import org.telegram.telegrambots.meta.exceptions.TelegramApiException; +import org.telegram.telegrambots.meta.exceptions.TelegramApiRequestException; + +import java.util.OptionalInt; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Sends messages to Telegram users on behalf of FSM actions. @@ -20,9 +26,12 @@ @RequiredArgsConstructor public class TelegramMessageSender { + private static final Pattern RETRY_AFTER_PATTERN = Pattern.compile("retry after (\\d+)"); + private final ObjectProvider telegramBotProvider; private final MessageLocalizationService messageLocalizationService; private final PersistentKeyboardService persistentKeyboardService; + private final TelegramChatPacer telegramChatPacer; /** * Send a localized notification to the user (e.g., guardrail warning). @@ -94,6 +103,58 @@ public void editHtml(Long chatId, Integer messageId, String htmlText, } } + public boolean editHtmlReliable(Long chatId, Integer messageId, String htmlText, + boolean disableWebPagePreview, long maxWaitMs) { + if (messageId == null) { + return false; + } + TelegramBot bot = telegramBotProvider.getIfAvailable(); + if (bot == null) { + log.warn("TelegramBot not available, cannot reliably edit message in chatId={}", chatId); + return false; + } + long startedAt = System.currentTimeMillis(); + for (int attempt = 1; attempt <= 2; attempt++) { + if (!reserveForReliable(chatId, startedAt, maxWaitMs)) { + return false; + } + try { + bot.editMessageHtml(chatId, messageId, htmlText, disableWebPagePreview); + return true; + } catch (TelegramApiException e) { + if (!sleepForRetryAfterIfPossible("edit", chatId, e, startedAt, maxWaitMs, attempt)) { + logTelegramFailure("edit", chatId, messageId, e); + return false; + } + } + } + return false; + } + + public Integer sendHtmlReliableAndGetId(Long chatId, String htmlText, Integer replyToMessageId, + boolean disableWebPagePreview, long maxWaitMs) { + TelegramBot bot = telegramBotProvider.getIfAvailable(); + if (bot == null) { + log.warn("TelegramBot not available, cannot reliably send message to chatId={}", chatId); + return null; + } + long startedAt = System.currentTimeMillis(); + for (int attempt = 1; attempt <= 2; attempt++) { + if (!reserveForReliable(chatId, startedAt, maxWaitMs)) { + return null; + } + try { + return bot.sendMessageAndGetId(chatId, htmlText, replyToMessageId, disableWebPagePreview); + } catch (TelegramApiException e) { + if (!sleepForRetryAfterIfPossible("send", chatId, e, startedAt, maxWaitMs, attempt)) { + logTelegramFailure("send", chatId, null, e); + return null; + } + } + } + return null; + } + /** * Delete a message in a chat. Returns {@code true} on success, {@code false} when the * bot is unavailable or Telegram refused the request (message too old, no rights, etc). @@ -138,4 +199,70 @@ private void sendHtml(Long chatId, String htmlText, Integer replyToMessageId, log.error("Failed to send message to chatId={}: {}", chatId, e.getMessage()); } } + + private boolean reserveForReliable(Long chatId, long startedAt, long maxWaitMs) { + long remainingMs = maxWaitMs - (System.currentTimeMillis() - startedAt); + if (remainingMs < 0) { + return false; + } + try { + return telegramChatPacer.reserve(chatId, remainingMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while waiting for Telegram chat pacing slot, chatId={}", chatId); + return false; + } + } + + private boolean sleepForRetryAfterIfPossible(String operation, Long chatId, TelegramApiException e, + long startedAt, long maxWaitMs, int attempt) { + OptionalInt retryAfter = parseRetryAfterSeconds(e); + if (retryAfter.isEmpty() || attempt >= 2) { + return false; + } + long sleepMs = retryAfter.getAsInt() * 1000L; + long elapsedMs = System.currentTimeMillis() - startedAt; + if (elapsedMs + sleepMs > maxWaitMs) { + log.warn("Telegram {} got 429 for chatId={} retryAfterSeconds={} exceeds remaining budget", + operation, chatId, retryAfter.getAsInt()); + return false; + } + log.warn("Telegram {} got 429 for chatId={}, retrying after {}s", + operation, chatId, retryAfter.getAsInt()); + try { + Thread.sleep(sleepMs); + return true; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while waiting for Telegram retry_after, chatId={}", chatId); + return false; + } + } + + public OptionalInt parseRetryAfterSeconds(TelegramApiException e) { + if (e instanceof TelegramApiRequestException requestException + && requestException.getParameters() != null + && requestException.getParameters().getRetryAfter() != null) { + return OptionalInt.of(requestException.getParameters().getRetryAfter()); + } + String message = e.getMessage(); + if (message == null) { + return OptionalInt.empty(); + } + Matcher matcher = RETRY_AFTER_PATTERN.matcher(message); + if (matcher.find()) { + return OptionalInt.of(Integer.parseInt(matcher.group(1))); + } + return OptionalInt.empty(); + } + + private void logTelegramFailure(String operation, Long chatId, Integer messageId, TelegramApiException e) { + if (parseRetryAfterSeconds(e).isPresent()) { + log.warn("Telegram {} failed with 429 for chatId={} messageId={}: {}", + operation, chatId, messageId, e.getMessage()); + } else { + log.error("Telegram {} failed for chatId={} messageId={}: {}", + operation, chatId, messageId, e.getMessage()); + } + } } diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java index 5890f174..ff8b87f0 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramAutoConfig.java @@ -75,4 +75,5 @@ public TelegramBotRegistrar telegramBotRegistrar(TelegramBot telegramBot, ObjectProvider menuServiceProvider) { return new TelegramBotRegistrar(telegramBot, menuServiceProvider); } + } diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java index 9d230f6f..10fb14e6 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java @@ -31,6 +31,9 @@ import io.github.ngirchev.opendaimon.telegram.service.InMemoryModelSelectionSession; import io.github.ngirchev.opendaimon.telegram.service.ModelSelectionSession; import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamRenderer; +import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamView; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacer; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacerImpl; import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; import io.github.ngirchev.opendaimon.telegram.service.UserModelPreferenceService; @@ -215,8 +218,16 @@ public ReplyImageAttachmentService replyImageAttachmentService( public TelegramMessageSender telegramMessageSender( ObjectProvider telegramBotProvider, MessageLocalizationService messageLocalizationService, - PersistentKeyboardService persistentKeyboardService) { - return new TelegramMessageSender(telegramBotProvider, messageLocalizationService, persistentKeyboardService); + PersistentKeyboardService persistentKeyboardService, + TelegramChatPacer telegramChatPacer) { + return new TelegramMessageSender(telegramBotProvider, messageLocalizationService, + persistentKeyboardService, telegramChatPacer); + } + + @Bean + @ConditionalOnMissingBean + public TelegramChatPacer telegramChatPacer(TelegramProperties telegramProperties) { + return new TelegramChatPacerImpl(telegramProperties); } @Bean @@ -225,6 +236,15 @@ public TelegramAgentStreamRenderer telegramAgentStreamRenderer(ObjectMapper obje return new TelegramAgentStreamRenderer(objectMapper); } + @Bean + @ConditionalOnMissingBean + public TelegramAgentStreamView telegramAgentStreamView( + TelegramMessageSender telegramMessageSender, + TelegramChatPacer telegramChatPacer, + TelegramProperties telegramProperties) { + return new TelegramAgentStreamView(telegramMessageSender, telegramChatPacer, telegramProperties); + } + @Bean @ConditionalOnMissingBean(MessageHandlerActions.class) @ConditionalOnProperty(prefix = FeatureToggle.TelegramCommand.PREFIX, name = FeatureToggle.TelegramCommand.MESSAGE, havingValue = "true", matchIfMissing = true) @@ -241,7 +261,7 @@ public TelegramMessageHandlerActions messageHandlerActions( ReplyImageAttachmentService replyImageAttachmentService, TelegramMessageSender telegramMessageSender, ObjectProvider agentExecutorProvider, - TelegramAgentStreamRenderer agentStreamRenderer, + TelegramAgentStreamView agentStreamView, // No default here — all defaults live in application.yml only (see coding-style.md) @Value("${open-daimon.agent.max-iterations}") int agentMaxIterations, @Value("${open-daimon.agent.enabled:false}") boolean defaultAgentModeEnabled) { @@ -250,7 +270,7 @@ public TelegramMessageHandlerActions messageHandlerActions( telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, chatSettingsService, persistentKeyboardService, replyImageAttachmentService, telegramMessageSender, - agentExecutorProvider.getIfAvailable(), agentStreamRenderer, agentMaxIterations, + agentExecutorProvider.getIfAvailable(), agentStreamView, agentMaxIterations, defaultAgentModeEnabled); } @@ -299,9 +319,10 @@ public PersistentKeyboardService persistentKeyboardService( ObjectProvider telegramBotProvider, TelegramProperties telegramProperties, MessageLocalizationService messageLocalizationService, - io.github.ngirchev.opendaimon.common.repository.UserRepository userRepository) { + io.github.ngirchev.opendaimon.common.repository.UserRepository userRepository, + TelegramChatPacer telegramChatPacer) { return new PersistentKeyboardService(coreCommonProperties, telegramBotProvider, - telegramProperties, messageLocalizationService, userRepository); + telegramProperties, messageLocalizationService, userRepository, telegramChatPacer); } @Bean @@ -339,4 +360,4 @@ public ModelTelegramCommandHandler modelTelegramCommandHandler( userRecentModelService ); } -} \ No newline at end of file +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramProperties.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramProperties.java index 7ed79afa..9d7847d4 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramProperties.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramProperties.java @@ -1,6 +1,7 @@ package io.github.ngirchev.opendaimon.telegram.config; import jakarta.annotation.PostConstruct; +import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -58,6 +59,14 @@ public String getNormalizedBotUsername() { */ private MessageCoalescing messageCoalescing = new MessageCoalescing(); + /** + * Agent stream Telegram view settings. The Java-side stream model may receive many + * provider chunks per second; this view flushes only current snapshots to Telegram. + */ + @NotNull + @Valid + private AgentStreamView agentStreamView = new AgentStreamView(); + @Getter @Setter public static class AccessConfig { @@ -106,19 +115,53 @@ public static class LevelConfig { private Integer maxMessageLength; /** - * Minimum interval between consecutive {@code editMessageText} calls on the - * same chat for agent streaming (milliseconds). Telegram Bot API throttles - * edits at roughly 1 per second per chat; bursts trigger 429 "Too Many - * Requests" with long retry windows. A chunk arriving within the window - * updates the in-memory buffer but skips the network call — the next chunk - * after the window expires flushes the accumulated text in one edit. Stream - * termination forces a final flush regardless of the window. + * UX phase pacing between structural agent stream transitions. Rate limiting is + * enforced chat-wide by {@link #agentStreamView}; this value only controls how long + * thinking/tool/result phases remain visible before being replaced. */ @NotNull(message = "agentStreamEditMinIntervalMs is required") @Min(value = 0, message = "agentStreamEditMinIntervalMs must be >= 0") @Max(value = 10000, message = "agentStreamEditMinIntervalMs must be <= 10000") private Integer agentStreamEditMinIntervalMs; + @Getter + @Setter + @Validated + public static class AgentStreamView { + /** + * Minimum interval between Telegram view flushes in private chats. + */ + @NotNull(message = "privateChatFlushIntervalMs is required") + @Min(value = 0, message = "privateChatFlushIntervalMs must be >= 0") + @Max(value = 10000, message = "privateChatFlushIntervalMs must be <= 10000") + private Integer privateChatFlushIntervalMs = 1000; + + /** + * Minimum interval between Telegram view flushes in groups/supergroups. + */ + @NotNull(message = "groupChatFlushIntervalMs is required") + @Min(value = 0, message = "groupChatFlushIntervalMs must be >= 0") + @Max(value = 60000, message = "groupChatFlushIntervalMs must be <= 60000") + private Integer groupChatFlushIntervalMs = 3000; + + /** + * Maximum time to wait for final answer delivery before the FSM reports a + * Telegram delivery error. + */ + @NotNull(message = "finalDeliveryTimeoutMs is required") + @Min(value = 0, message = "finalDeliveryTimeoutMs must be >= 0") + @Max(value = 60000, message = "finalDeliveryTimeoutMs must be <= 60000") + private Integer finalDeliveryTimeoutMs = 5000; + + /** + * Maximum time non-final sends may wait for the chat pacing slot. + */ + @NotNull(message = "defaultAcquireTimeoutMs is required") + @Min(value = 0, message = "defaultAcquireTimeoutMs must be >= 0") + @Max(value = 10000, message = "defaultAcquireTimeoutMs must be <= 10000") + private Integer defaultAcquireTimeoutMs = 1000; + } + @Getter @Setter public static class Commands { diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/InMemoryModelSelectionSession.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/InMemoryModelSelectionSession.java index 10eeb2cf..56c4409d 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/InMemoryModelSelectionSession.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/InMemoryModelSelectionSession.java @@ -23,13 +23,12 @@ public class InMemoryModelSelectionSession implements ModelSelectionSession { @Override public List getOrFetch(Long userId, Supplier> fetcher) { - CachedModelList cached = userCache.get(userId); - if (cached != null && cached.createdAt().isAfter(Instant.now().minusSeconds(TTL_SECONDS))) { - return cached.models(); - } - List models = fetcher.get(); - userCache.put(userId, new CachedModelList(List.copyOf(models), Instant.now())); - return models; + return userCache.compute(userId, (k, v) -> { + if (v != null && v.createdAt().isAfter(Instant.now().minusSeconds(TTL_SECONDS))) { + return v; + } + return new CachedModelList(List.copyOf(fetcher.get()), Instant.now()); + }).models(); } @Override diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardService.java index 74b0e2b7..a58c7b03 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardService.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardService.java @@ -26,17 +26,20 @@ public class PersistentKeyboardService { private final TelegramProperties telegramProperties; private final MessageLocalizationService messageLocalizationService; private final UserRepository userRepository; + private final TelegramChatPacer telegramChatPacer; public PersistentKeyboardService(CoreCommonProperties coreCommonProperties, ObjectProvider telegramBotProvider, TelegramProperties telegramProperties, MessageLocalizationService messageLocalizationService, - UserRepository userRepository) { + UserRepository userRepository, + TelegramChatPacer telegramChatPacer) { this.coreCommonProperties = coreCommonProperties; this.telegramBotProvider = telegramBotProvider; this.telegramProperties = telegramProperties; this.messageLocalizationService = messageLocalizationService; this.userRepository = userRepository; + this.telegramChatPacer = telegramChatPacer; } /** @@ -73,12 +76,27 @@ public void sendKeyboard(Long chatId, Long userId, ConversationThread thread, St markup.setResizeKeyboard(true); markup.setOneTimeKeyboard(false); msg.setReplyMarkup(markup); + long timeoutMs = keyboardAcquireTimeoutMs(chatId); + if (!telegramChatPacer.reserve(chatId, timeoutMs)) { + log.warn("Skipped persistent keyboard send to chat {} because chat pacing slot was unavailable after {}ms", + chatId, timeoutMs); + return; + } telegramBotProvider.getObject().execute(msg); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while sending persistent keyboard to chat {}", chatId); } catch (Exception e) { log.warn("Failed to send persistent keyboard to chat {}: {}", chatId, e.getMessage()); } } + private long keyboardAcquireTimeoutMs(Long chatId) { + long defaultTimeoutMs = telegramProperties.getAgentStreamView().getDefaultAcquireTimeoutMs(); + long pacingIntervalMs = telegramChatPacer.intervalMs(chatId); + return defaultTimeoutMs + pacingIntervalMs; + } + /** * Builds the reply keyboard markup without sending it. * Keyboard button labels always reflect the stored DB preference. diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModel.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModel.java new file mode 100644 index 00000000..78897cc2 --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModel.java @@ -0,0 +1,342 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.ngirchev.opendaimon.common.agent.AgentStreamEvent; +import io.github.ngirchev.opendaimon.common.service.AIUtils; + +/** + * Provider-neutral model for one agent stream. + * + *

The Spring AI agent loop emits the same {@link AgentStreamEvent} sequence for + * OpenRouter, Ollama, and any future provider. This model keeps that stream as local + * state first, then lets Telegram render periodic snapshots from it. A + * {@code PARTIAL_ANSWER} is only a candidate while the current iteration is still open: + * a later tool call can prove it was pre-tool reasoning. Only terminal + * {@code FINAL_ANSWER}/{@code MAX_ITERATIONS} content becomes the confirmed user answer. + */ +public final class TelegramAgentStreamModel { + + public static final String STATUS_THINKING_LINE = "💭 Thinking..."; + public static final String STATUS_MAX_ITER_LINE = "⚠️ reached iteration limit"; + + private static final int CANDIDATE_TAIL_LIMIT = 400; + private static final String MISSING_TOOL_ARGUMENT = "missing"; + + private final boolean silent; + private final boolean preserveReasoning; + private final ObjectMapper objectMapper; + private final StringBuilder statusHtml = new StringBuilder(); + private final StringBuilder candidateEscaped = new StringBuilder(); + private String confirmedAnswer; + private boolean statusDirty; + private boolean answerDirty; + private int currentIteration = -1; + private boolean toolCallSeenThisIteration; + + public TelegramAgentStreamModel(boolean silent, boolean preserveReasoning) { + this(silent, preserveReasoning, new ObjectMapper()); + } + + public TelegramAgentStreamModel(boolean silent, boolean preserveReasoning, ObjectMapper objectMapper) { + this.silent = silent; + this.preserveReasoning = preserveReasoning; + this.objectMapper = objectMapper; + if (!silent) { + statusHtml.append(STATUS_THINKING_LINE); + statusDirty = true; + currentIteration = 0; + } + } + + public void apply(AgentStreamEvent event) { + if (event == null) { + return; + } + switch (event.type()) { + case METADATA -> { + // Side-channel metadata is handled by the FSM context. + } + case THINKING -> applyThinking(event); + case PARTIAL_ANSWER -> applyPartialAnswer(event); + case TOOL_CALL -> applyToolCall(event); + case OBSERVATION -> applyObservation(event); + case FINAL_ANSWER -> confirmAnswer(event.content()); + case MAX_ITERATIONS -> applyMaxIterations(event.content()); + case ERROR -> appendStatus("\n\n❌ Error: " + TelegramHtmlEscaper.escape(nullToEmpty(event.content()))); + } + } + + public String statusHtml() { + return statusHtml.toString(); + } + + public String answerHtml() { + return confirmedAnswer == null ? "" : AIUtils.convertMarkdownToHtml(confirmedAnswer); + } + + public String answerText() { + return confirmedAnswer == null ? "" : confirmedAnswer; + } + + public boolean hasStatus() { + return !silent && !statusHtml.isEmpty(); + } + + public boolean hasConfirmedAnswer() { + return confirmedAnswer != null && !confirmedAnswer.isBlank(); + } + + public boolean isStatusDirty() { + return statusDirty; + } + + public boolean isAnswerDirty() { + return answerDirty; + } + + public void markStatusClean() { + statusDirty = false; + } + + public void markAnswerClean() { + answerDirty = false; + } + + public int currentIteration() { + return currentIteration; + } + + public boolean isToolCallSeenThisIteration() { + return toolCallSeenThisIteration; + } + + public boolean hasCandidateText() { + return candidateEscaped.length() > 0; + } + + private void applyThinking(AgentStreamEvent event) { + boolean newIteration = event.iteration() != currentIteration; + updateIteration(event.iteration()); + if (silent) { + return; + } + String content = event.content(); + if (content == null || content.isBlank()) { + if (statusHtml.isEmpty()) { + appendStatus(STATUS_THINKING_LINE); + } else if (newIteration) { + appendStatus("\n\n" + STATUS_THINKING_LINE); + } + return; + } + String reasoningHtml = "" + collapseToSingleLine(TelegramHtmlEscaper.escape(content)) + ""; + if (statusHtml.toString().endsWith("") || statusHtml.toString().endsWith(STATUS_THINKING_LINE)) { + replaceTrailingLine(reasoningHtml); + } else { + appendStatus("\n\n" + reasoningHtml); + } + } + + private void applyPartialAnswer(AgentStreamEvent event) { + updateIteration(event.iteration()); + String chunk = event.content(); + if (chunk == null || chunk.isEmpty()) { + return; + } + candidateEscaped.append(TelegramHtmlEscaper.escape(chunk)); + if (!silent && !toolCallSeenThisIteration) { + replaceTrailingLine(candidateTailOverlay()); + } + } + + private void applyToolCall(AgentStreamEvent event) { + updateIteration(event.iteration()); + toolCallSeenThisIteration = true; + if (silent) { + candidateEscaped.setLength(0); + return; + } + ToolCallParts parts = parseToolCall(event.content()); + String blockBody = renderToolCallBlock(parts.toolName(), parts.args()); + if (preserveReasoning) { + if (candidateEscaped.length() > 0) { + replaceTrailingLine(candidateTailOverlay()); + } + appendStatus("\n\n" + blockBody); + } else { + replaceTrailingLine(blockBody); + } + candidateEscaped.setLength(0); + } + + private void applyObservation(AgentStreamEvent event) { + if (silent) { + return; + } + String body; + if (event.error()) { + body = "⚠️ Tool failed: " + TelegramHtmlEscaper.escape(nullToEmpty(event.content())); + } else if (event.content() == null || event.content().isBlank() + || "(no tool output)".equals(event.content())) { + body = "📋 No result"; + } else { + body = "📋 Tool result received"; + } + appendStatus("\n

" + body + "
"); + } + + private void applyMaxIterations(String content) { + confirmAnswer(content); + if (!silent) { + appendStatus("\n\n" + STATUS_MAX_ITER_LINE); + } + } + + public void confirmAnswer(String content) { + if (content == null || content.isBlank()) { + return; + } + if (content.equals(confirmedAnswer)) { + return; + } + confirmedAnswer = content; + clearTrailingStatusOverlay(); + candidateEscaped.setLength(0); + answerDirty = true; + } + + /** + * Drops the trailing italic status line after answer confirmation when it is either + * a streamed answer candidate or hidden reasoning. SHOW_ALL keeps pure reasoning + * overlays, but still removes candidate overlays to avoid duplicating the answer. + */ + private void clearTrailingStatusOverlay() { + if (silent) { + return; + } + boolean candidateOverlayRendered = candidateEscaped.length() > 0; + if (preserveReasoning && !candidateOverlayRendered) { + return; + } + String html = statusHtml.toString(); + if (!html.endsWith("")) { + return; + } + int lastBoundary = html.lastIndexOf("\n\n"); + int trailingLineStart = lastBoundary >= 0 ? lastBoundary + 2 : 0; + if (!html.startsWith("", trailingLineStart)) { + return; + } + if (lastBoundary >= 0) { + statusHtml.setLength(lastBoundary); + } else { + // Overlay was the only content; Telegram rejects empty edits, so leave a + // minimal completion marker. + statusHtml.setLength(0); + statusHtml.append("✅"); + } + statusDirty = true; + } + + private void updateIteration(int iteration) { + if (iteration != currentIteration) { + currentIteration = iteration; + toolCallSeenThisIteration = false; + candidateEscaped.setLength(0); + } + } + + private void appendStatus(String escapedHtml) { + if (escapedHtml == null || escapedHtml.isEmpty()) { + return; + } + statusHtml.append(escapedHtml); + statusDirty = true; + } + + private void replaceTrailingLine(String escapedHtml) { + int lastBoundary = statusHtml.lastIndexOf("\n\n"); + int cut = lastBoundary >= 0 ? lastBoundary + 2 : 0; + statusHtml.setLength(cut); + statusHtml.append(escapedHtml); + statusDirty = true; + } + + private String candidateTailOverlay() { + int rawStart = Math.max(0, candidateEscaped.length() - CANDIDATE_TAIL_LIMIT); + int wordStart = rawStart; + if (rawStart > 0) { + // Skip forward to the next whitespace so the tail starts on a word boundary. + // Without this, a `**bold**` pair can be sliced mid-marker and the regex in + // AIUtils.applyMarkdownReplacements leaves the orphan `**` visible in chat. + for (int i = rawStart; i < candidateEscaped.length(); i++) { + char c = candidateEscaped.charAt(i); + if (c == ' ' || c == '\n' || c == '\t') { + wordStart = i + 1; + break; + } + } + } + String tailEscaped = candidateEscaped.substring(wordStart); + String tailHtml = AIUtils.convertEscapedMarkdownToHtml(collapseToSingleLine(tailEscaped)); + return "" + tailHtml + ""; + } + + private String renderToolCallBlock(String toolName, String args) { + String label = ToolLabels.label(toolName); + String escapedArgs = args == null || args.isBlank() + ? "" + : TelegramHtmlEscaper.escape(ToolLabels.truncateArg(args)); + return escapedArgs.isEmpty() + ? "🔧 Tool: " + label + "\nQuery: " + MISSING_TOOL_ARGUMENT + : "🔧 Tool: " + label + "\nQuery: " + escapedArgs; + } + + private ToolCallParts parseToolCall(String content) { + if (content == null || content.isBlank()) { + return new ToolCallParts("", ""); + } + int colonIndex = content.indexOf(": "); + String toolName = colonIndex >= 0 ? content.substring(0, colonIndex) : content; + String argsJson = colonIndex >= 0 ? content.substring(colonIndex + 2) : ""; + String friendlyArg = extractFriendlyArg(argsJson); + return new ToolCallParts(toolName, friendlyArg != null ? friendlyArg : ""); + } + + private String extractFriendlyArg(String argsJson) { + if (argsJson == null || argsJson.isBlank()) { + return null; + } + try { + JsonNode node = objectMapper.readTree(argsJson); + if (!node.isObject()) { + return null; + } + var fields = node.fields(); + while (fields.hasNext()) { + JsonNode value = fields.next().getValue(); + if (value.isTextual() && !value.asText().isBlank()) { + return value.asText(); + } + } + return null; + } catch (JsonProcessingException e) { + return null; + } + } + + private static String collapseToSingleLine(String value) { + if (value == null || value.isEmpty()) { + return value; + } + return value.replaceAll("\\s+", " ").trim(); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private record ToolCallParts(String toolName, String args) {} +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamView.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamView.java new file mode 100644 index 00000000..3e1f9b68 --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamView.java @@ -0,0 +1,219 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageSender; +import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; +import io.github.ngirchev.opendaimon.common.service.AIUtils; +import lombok.extern.slf4j.Slf4j; + +/** + * Telegram view for an agent stream model. + * + *

The view sends/edit snapshots. It does not own model state and it does not queue + * historical operations; skipped partial flushes are fine because the next flush renders + * the latest model contents. + * + *

Stateless singleton — all per-request render state (including the progressive + * rendered offset) lives on {@link MessageHandlerContext}. Adding mutable instance fields + * here would re-introduce TD-1 race condition between concurrent agent streams. + */ +@Slf4j +public final class TelegramAgentStreamView { + + private final TelegramMessageSender messageSender; + private final TelegramChatPacer telegramChatPacer; + private final TelegramProperties telegramProperties; + + public TelegramAgentStreamView(TelegramMessageSender messageSender, + TelegramChatPacer telegramChatPacer, + TelegramProperties telegramProperties) { + this.messageSender = messageSender; + this.telegramChatPacer = telegramChatPacer; + this.telegramProperties = telegramProperties; + } + + public void flush(MessageHandlerContext ctx, TelegramAgentStreamModel model) { + flush(ctx, model, false); + } + + public boolean flushFinal(MessageHandlerContext ctx, TelegramAgentStreamModel model) { + flushStatus(ctx, model, true); + return flushAnswer(ctx, model, true); + } + + public void flush(MessageHandlerContext ctx, TelegramAgentStreamModel model, boolean force) { + flushStatus(ctx, model, force); + flushAnswer(ctx, model, force); + } + + private boolean flushStatus(MessageHandlerContext ctx, TelegramAgentStreamModel model, boolean force) { + if (!model.hasStatus() || (!force && !model.isStatusDirty())) { + return true; + } + Long chatId = ctx.getCommand().telegramId(); + if (!force && !reserveForView(chatId, false)) { + return !force; + } + String fullHtml = model.statusHtml(); + if (ctx.getStatusRenderedOffset() > fullHtml.length()) { + ctx.setStatusRenderedOffset(0); + } + String html = fullHtml.substring(ctx.getStatusRenderedOffset()); + Integer statusId = ctx.getStatusMessageId(); + long reliableTimeoutMs = telegramProperties.getAgentStreamView().getFinalDeliveryTimeoutMs(); + if (statusId == null) { + Integer sentId = messageSender.sendHtmlAndGetId( + chatId, html, ctx.consumeNextReplyToMessageId(), true); + if (sentId == null) { + return false; + } + ctx.setStatusMessageId(sentId); + ctx.markStatusEdited(); + } else { + StringBuilder current = new StringBuilder(html); + var rotated = TelegramProgressBatcher.selectContentToFlush( + current, telegramProperties.getMaxMessageLength()); + if (rotated.isPresent()) { + if (!editStatus(chatId, statusId, rotated.get(), force, reliableTimeoutMs)) { + return deleteStaleStatus(ctx, chatId, statusId, force); + } + ctx.setStatusRenderedOffset(fullHtml.length() - current.length()); + Integer nextId = force + ? messageSender.sendHtmlReliableAndGetId( + chatId, current.toString(), null, true, reliableTimeoutMs) + : messageSender.sendHtmlAndGetId(chatId, current.toString(), null, true); + if (nextId != null) { + ctx.setStatusMessageId(nextId); + ctx.markStatusEdited(); + ctx.setAlreadySentInStream(true); + model.markStatusClean(); + return true; + } + return false; + } + if (!editStatus(chatId, statusId, html, force, reliableTimeoutMs)) { + return deleteStaleStatus(ctx, chatId, statusId, force); + } + ctx.markStatusEdited(); + } + ctx.setAlreadySentInStream(true); + model.markStatusClean(); + return true; + } + + private boolean editStatus(Long chatId, Integer statusId, String html, boolean reliable, long maxWaitMs) { + if (reliable) { + return messageSender.editHtmlReliable(chatId, statusId, html, true, maxWaitMs); + } + messageSender.editHtml(chatId, statusId, html, true); + return true; + } + + private boolean deleteStaleStatus(MessageHandlerContext ctx, Long chatId, Integer statusId, boolean force) { + if (!force) { + return false; + } + log.warn("Final status edit failed for chatId={}, statusId={}; deleting stale status message", + chatId, statusId); + if (!messageSender.deleteMessage(chatId, statusId)) { + return false; + } + ctx.setStatusMessageId(null); + ctx.setStatusRenderedOffset(0); + ctx.setAlreadySentInStream(true); + return true; + } + + private boolean flushAnswer(MessageHandlerContext ctx, TelegramAgentStreamModel model, boolean force) { + if (!model.hasConfirmedAnswer() || (!force && !model.isAnswerDirty())) { + return true; + } + Long chatId = ctx.getCommand().telegramId(); + String html = model.answerHtml(); + long maxWaitMs = telegramProperties.getAgentStreamView().getFinalDeliveryTimeoutMs(); + Integer answerId = ctx.getTentativeAnswerMessageId(); + if (answerId == null) { + Integer replyTo = ctx.getMessage() != null ? ctx.getMessage().getMessageId() : null; + Integer sentId = sendAnswerChunks(chatId, model.answerText(), replyTo, maxWaitMs); + if (sentId == null) { + log.error("Final Telegram answer send failed for chatId={}", chatId); + return false; + } + ctx.setTentativeAnswerMessageId(sentId); + ctx.markAnswerEdited(); + } else if (!messageSender.editHtmlReliable(chatId, answerId, html, false, maxWaitMs)) { + Integer sentId = messageSender.sendHtmlReliableAndGetId( + chatId, html, null, false, maxWaitMs); + if (sentId == null) { + log.error("Final Telegram answer edit and fallback send failed for chatId={}", chatId); + return false; + } + ctx.setTentativeAnswerMessageId(sentId); + ctx.markAnswerEdited(); + } else { + ctx.markAnswerEdited(); + } + ctx.setTentativeAnswerActive(false); + ctx.setAlreadySentInStream(true); + model.markAnswerClean(); + return true; + } + + private Integer sendAnswerChunks(Long chatId, String answerText, Integer replyTo, long maxWaitMs) { + int maxLength = telegramProperties.getMaxMessageLength(); + if (answerText.length() <= maxLength) { + return messageSender.sendHtmlReliableAndGetId( + chatId, AIUtils.convertMarkdownToHtml(answerText), replyTo, false, maxWaitMs); + } + Integer lastId = null; + String[] paragraphs = answerText.split("\n\n"); + StringBuilder buffer = new StringBuilder(); + Integer currentReplyTo = replyTo; + for (String paragraph : paragraphs) { + while (paragraph.length() > maxLength) { + if (!buffer.isEmpty()) { + lastId = sendAnswerChunk(chatId, buffer.toString().trim(), currentReplyTo, maxWaitMs); + if (lastId == null) { + return null; + } + currentReplyTo = null; + buffer.setLength(0); + } + String chunk = paragraph.substring(0, maxLength); + lastId = sendAnswerChunk(chatId, chunk, currentReplyTo, maxWaitMs); + if (lastId == null) { + return null; + } + currentReplyTo = null; + paragraph = paragraph.substring(maxLength); + } + if (!buffer.isEmpty()) { + buffer.append("\n\n"); + } + buffer.append(paragraph); + } + if (!buffer.isEmpty()) { + lastId = sendAnswerChunk(chatId, buffer.toString().trim(), currentReplyTo, maxWaitMs); + } + return lastId; + } + + private Integer sendAnswerChunk(Long chatId, String markdown, Integer replyTo, long maxWaitMs) { + return messageSender.sendHtmlReliableAndGetId( + chatId, AIUtils.convertMarkdownToHtml(markdown), replyTo, false, maxWaitMs); + } + + private boolean reserveForView(Long chatId, boolean force) { + if (!force) { + return telegramChatPacer.tryReserve(chatId); + } + long timeoutMs = telegramProperties.getAgentStreamView().getDefaultAcquireTimeoutMs(); + try { + return telegramChatPacer.reserve(chatId, timeoutMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while waiting for Telegram stream view pacing slot, chatId={}", chatId); + return false; + } + } +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatPacer.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatPacer.java new file mode 100644 index 00000000..5018ea2e --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatPacer.java @@ -0,0 +1,16 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +/** + * Chat-scoped pacing gate for outbound Telegram operations. + * + *

This is not a dispatcher queue. Callers keep their own semantic buffers and ask the + * pacer only when they are ready to send a current snapshot to Telegram. + */ +public interface TelegramChatPacer { + + boolean tryReserve(long chatId); + + boolean reserve(long chatId, long timeoutMs) throws InterruptedException; + + long intervalMs(long chatId); +} diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatPacerImpl.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatPacerImpl.java new file mode 100644 index 00000000..727726ec --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramChatPacerImpl.java @@ -0,0 +1,67 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; + +import java.time.Duration; + +public class TelegramChatPacerImpl implements TelegramChatPacer { + + private final TelegramProperties telegramProperties; + private final Cache slots = Caffeine.newBuilder() + .expireAfterAccess(Duration.ofHours(1)) + .build(); + + public TelegramChatPacerImpl(TelegramProperties telegramProperties) { + this.telegramProperties = telegramProperties; + } + + @Override + public boolean tryReserve(long chatId) { + return slots.get(chatId, ignored -> new ChatSlot()) + .tryReserve(System.currentTimeMillis(), intervalMs(chatId)); + } + + @Override + public boolean reserve(long chatId, long timeoutMs) throws InterruptedException { + return slots.get(chatId, ignored -> new ChatSlot()) + .reserve(System.currentTimeMillis(), intervalMs(chatId), timeoutMs); + } + + @Override + public long intervalMs(long chatId) { + TelegramProperties.AgentStreamView view = telegramProperties.getAgentStreamView(); + return chatId < 0 ? view.getGroupChatFlushIntervalMs() : view.getPrivateChatFlushIntervalMs(); + } + + private static final class ChatSlot { + + private long nextAllowedAtMs; + + synchronized boolean tryReserve(long nowMs, long intervalMs) { + if (nowMs < nextAllowedAtMs) { + return false; + } + nextAllowedAtMs = nowMs + intervalMs; + notifyAll(); + return true; + } + + synchronized boolean reserve(long nowMs, long intervalMs, long timeoutMs) throws InterruptedException { + long deadlineMs = nowMs + Math.max(0, timeoutMs); + long now = nowMs; + while (now < nextAllowedAtMs) { + long waitMs = Math.min(nextAllowedAtMs - now, deadlineMs - now); + if (waitMs <= 0) { + return false; + } + wait(waitMs); + now = System.currentTimeMillis(); + } + nextAllowedAtMs = now + intervalMs; + notifyAll(); + return true; + } + } +} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java index e8c2418d..46ab93e8 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/MessageTelegramCommandHandlerTest.java @@ -24,6 +24,8 @@ import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerState; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageHandlerActions; import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageSender; +import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamView; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -103,7 +105,7 @@ class MessageTelegramCommandHandlerTest { private MessageTelegramCommandHandler handler; @BeforeEach - void setUp() { + void setUp() throws Exception { ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); messageSource.setBasenames("classpath:messages/common", "classpath:messages/telegram"); messageSource.setDefaultEncoding("UTF-8"); @@ -117,16 +119,21 @@ void setUp() { ObjectProvider botProvider = mock(ObjectProvider.class); when(botProvider.getObject()).thenReturn(telegramBot); when(botProvider.getIfAvailable()).thenReturn(telegramBot); + TelegramChatPacer telegramChatPacer = mock(TelegramChatPacer.class); + when(telegramChatPacer.tryReserve(anyLong())).thenReturn(true); + when(telegramChatPacer.reserve(anyLong(), anyLong())).thenReturn(true); TelegramMessageSender messageSender = new TelegramMessageSender( - botProvider, messageLocalizationService, persistentKeyboardService); + botProvider, messageLocalizationService, persistentKeyboardService, telegramChatPacer); + TelegramAgentStreamView agentStreamView = new TelegramAgentStreamView( + messageSender, telegramChatPacer, telegramProperties); TelegramMessageHandlerActions actions = new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, chatSettingsService, persistentKeyboardService, replyImageAttachmentService, messageSender, - null, null, 10, false); + null, agentStreamView, 10, false); ExDomainFsm handlerFsm = MessageHandlerFsmFactory.create(actions); diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerContextTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerContextTest.java new file mode 100644 index 00000000..c1abff0d --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/MessageHandlerContextTest.java @@ -0,0 +1,47 @@ +package io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm; + +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.telegram.telegrambots.meta.api.objects.Message; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Targeted unit coverage for the {@code statusRenderedOffset} accessor pair on + * {@link MessageHandlerContext}. The field was migrated from the singleton + * {@code TelegramAgentStreamView} as part of TD-1 (state isolation) and joins the + * progressive-cursor precedent set by {@code toolMarkerScanOffset}. + */ +class MessageHandlerContextTest { + + /** + * Covers: REQ-2 (Context owns offset). + * Default getter must return 0 (Java int default — no explicit initializer). + * Setter must round-trip the value verbatim. + */ + @Test + @DisplayName("should round-trip statusRenderedOffset through getter and setter") + void shouldRoundtripStatusRenderedOffset() { + TelegramCommand command = mock(TelegramCommand.class); + Message message = mock(Message.class); + MessageHandlerContext ctx = new MessageHandlerContext(command, message, s -> {}); + + assertThat(ctx.getStatusRenderedOffset()) + .as("statusRenderedOffset must default to 0 (Java int default)") + .isZero(); + + ctx.setStatusRenderedOffset(1500); + + assertThat(ctx.getStatusRenderedOffset()) + .as("setter must persist the value verbatim") + .isEqualTo(1500); + + ctx.setStatusRenderedOffset(0); + + assertThat(ctx.getStatusRenderedOffset()) + .as("setter must support resetting to 0 (used by the rotation guard)") + .isZero(); + } +} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java index f476ddd2..0e05c768 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsAgentTest.java @@ -19,11 +19,14 @@ import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamRenderer; +import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamView; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacer; import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -50,6 +53,7 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.atMost; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -72,6 +76,7 @@ class TelegramMessageHandlerActionsAgentTest { @Mock private ReplyImageAttachmentService replyImageAttachmentService; @Mock private TelegramMessageSender messageSender; @Mock private AgentExecutor agentExecutor; + @Mock private TelegramChatPacer telegramChatPacer; private TelegramAgentStreamRenderer agentStreamRenderer; private TelegramMessageHandlerActions actions; @@ -86,13 +91,25 @@ void setUp() { // event produces a Telegram call and the tests can assert on it directly. telegramProperties.setAgentStreamEditMinIntervalMs(0); agentStreamRenderer = new TelegramAgentStreamRenderer(new ObjectMapper()); + lenient().when(telegramChatPacer.tryReserve(anyLong())).thenReturn(true); + try { + lenient().when(telegramChatPacer.reserve(anyLong(), anyLong())).thenReturn(true); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + TelegramAgentStreamView agentStreamView = new TelegramAgentStreamView( + messageSender, telegramChatPacer, telegramProperties); + lenient().when(messageSender.sendHtmlReliableAndGetId(eq(12345L), anyString(), any(), anyBoolean(), anyLong())) + .thenReturn(777); + lenient().when(messageSender.editHtmlReliable(eq(12345L), any(), anyString(), anyBoolean(), anyLong())) + .thenReturn(true); actions = new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, chatSettingsService, persistentKeyboardService, replyImageAttachmentService, messageSender, - agentExecutor, agentStreamRenderer, MAX_ITERATIONS, true); + agentExecutor, agentStreamView, MAX_ITERATIONS, true); } @Test @@ -533,6 +550,7 @@ void shouldLookupAiGatewayInCreateCommandWhenAgentExecutorPresentButUserDisabled // fresh paragraph-batched message carries the FINAL_ANSWER. @Nested + @Disabled("Superseded by TelegramAgentStreamModel/TelegramMessageHandlerActionsStreamingTest model-view tests") @DisplayName("Two-message orchestration") class TwoMessageOrchestration { diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java index dea9aad0..a5be84b6 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsStreamingTest.java @@ -15,6 +15,8 @@ import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamRenderer; +import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamView; +import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacer; import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageService; import io.github.ngirchev.opendaimon.telegram.model.TelegramUser; import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; @@ -37,6 +39,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; @@ -76,6 +79,7 @@ class TelegramMessageHandlerActionsStreamingTest { @Mock private ReplyImageAttachmentService replyImageAttachmentService; @Mock private TelegramMessageSender messageSender; @Mock private AgentExecutor agentExecutor; + @Mock private TelegramChatPacer telegramChatPacer; private TelegramAgentStreamRenderer agentStreamRenderer; private TelegramMessageHandlerActions actions; @@ -88,17 +92,29 @@ void setUp() { // Disable throttling so every event produces a Telegram call we can assert on. telegramProperties.setAgentStreamEditMinIntervalMs(0); agentStreamRenderer = new TelegramAgentStreamRenderer(new ObjectMapper()); + when(telegramChatPacer.tryReserve(anyLong())).thenReturn(true); + try { + when(telegramChatPacer.reserve(anyLong(), anyLong())).thenReturn(true); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + TelegramAgentStreamView agentStreamView = new TelegramAgentStreamView( + messageSender, telegramChatPacer, telegramProperties); + when(messageSender.sendHtmlReliableAndGetId(eq(CHAT_ID), anyString(), any(), anyBoolean(), anyLong())) + .thenReturn(ANSWER_MSG_ID); + when(messageSender.editHtmlReliable(eq(CHAT_ID), any(), anyString(), anyBoolean(), anyLong())) + .thenReturn(true); actions = new TelegramMessageHandlerActions( telegramUserService, telegramUserSessionService, telegramMessageService, aiGatewayRegistry, messageService, aiRequestPipeline, telegramProperties, chatSettingsService, persistentKeyboardService, replyImageAttachmentService, messageSender, - agentExecutor, agentStreamRenderer, MAX_ITERATIONS, true); + agentExecutor, agentStreamView, MAX_ITERATIONS, true); } @Test - @DisplayName("should promote answer bubble on the first PARTIAL_ANSWER when no tool call has been seen") + @DisplayName("should keep partial answer in status and send answer only after FINAL_ANSWER") void shouldPromoteAnswerBubbleOnFirstPartialAnswerWhenNoToolCall() { MessageHandlerContext ctx = createContextWithMessage("Ask", Set.of(ModelCapabilities.WEB)); @@ -124,15 +140,13 @@ void shouldPromoteAnswerBubbleOnFirstPartialAnswerWhenNoToolCall() { actions.generateResponse(ctx); - // Exactly one answer bubble send — threaded reply to the user message, content - // distinguished from the status bubble by the absence of the thinking marker. - verify(messageSender, times(1)).sendHtmlAndGetId(eq(CHAT_ID), + verify(messageSender, times(1)).sendHtmlReliableAndGetId(eq(CHAT_ID), argThat(html -> html != null && html.contains("Quick single-line reply.") && !html.contains(STATUS_THINKING_LINE)), - eq(USER_MSG_ID), eq(true)); + eq(USER_MSG_ID), eq(false), eq(5000L)); assertThat(ctx.getAgentRenderMode()) - .isEqualTo(MessageHandlerContext.AgentRenderMode.TENTATIVE_ANSWER); + .isEqualTo(MessageHandlerContext.AgentRenderMode.STATUS_ONLY); assertThat(ctx.getTentativeAnswerMessageId()).isEqualTo(ANSWER_MSG_ID); assertThat(ctx.getErrorType()).isNull(); } @@ -164,12 +178,12 @@ void shouldRollbackBubbleWhenToolMarkerArrivesAfterPromotion() { actions.generateResponse(ctx); - verify(messageSender, times(1)).deleteMessage(eq(CHAT_ID), eq(ANSWER_MSG_ID)); + verify(messageSender, never()).deleteMessage(eq(CHAT_ID), eq(ANSWER_MSG_ID)); assertThat(ctx.isTentativeAnswerActive()).isFalse(); - assertThat(ctx.isToolCallSeenThisIteration()).isTrue(); - assertThat(ctx.getTentativeAnswerBuffer().length()) - .as("tentative-answer buffer should be cleared after rollback") - .isZero(); + assertThat(ctx.getTentativeAnswerMessageId()).isEqualTo(ANSWER_MSG_ID); + verify(messageSender, times(1)).sendHtmlReliableAndGetId(eq(CHAT_ID), + argThat(html -> html != null && html.contains("Real answer.")), + eq(USER_MSG_ID), eq(false), eq(5000L)); } @Test @@ -203,7 +217,7 @@ void shouldRollbackBubbleWhenToolCallEventArrivesAfterPromotion() { actions.generateResponse(ctx); - verify(messageSender, times(1)).deleteMessage(eq(CHAT_ID), eq(ANSWER_MSG_ID)); + verify(messageSender, never()).deleteMessage(eq(CHAT_ID), eq(ANSWER_MSG_ID)); assertThat(ctx.isTentativeAnswerActive()).isFalse(); ArgumentCaptor statusEditCaptor = ArgumentCaptor.forClass(String.class); @@ -238,12 +252,12 @@ void shouldNotPromoteWhenToolCallAlreadySeenInIteration() { actions.generateResponse(ctx); - // No answer bubble was opened — only the status send (replying to the user - // message) should have hit sendHtmlAndGetId. + // No speculative answer bubble was opened via regular send; final answer is + // delivered only through the reliable final-answer path. verify(messageSender, never()) .sendHtmlAndGetId(eq(CHAT_ID), anyString(), isNull(), anyBoolean()); assertThat(ctx.isTentativeAnswerActive()).isFalse(); - assertThat(ctx.getTentativeAnswerMessageId()).isNull(); + assertThat(ctx.getTentativeAnswerMessageId()).isEqualTo(ANSWER_MSG_ID); assertThat(ctx.getAgentRenderMode()) .isEqualTo(MessageHandlerContext.AgentRenderMode.STATUS_ONLY); } @@ -349,12 +363,10 @@ void shouldRenderFinalAnswerBubbleOnMaxIterations() { // (b) status transcript records the ⚠️ iteration-limit marker. assertThat(ctx.getStatusBuffer().toString()).contains(STATUS_MAX_ITER_LINE); - // (c) the answer was actually delivered to the chat — generateAgentResponse routes the - // no-tentative-bubble path through sendHtml(chatId, html, null) per paragraph. The - // html is produced by AIUtils.convertMarkdownToHtml, so assert on the payload the - // user would actually see (plain sentence survives conversion). + // (c) the answer was actually delivered to the chat through the reliable final-answer path. ArgumentCaptor sentHtmlCaptor = ArgumentCaptor.forClass(String.class); - verify(messageSender, atLeastOnce()).sendHtml(eq(CHAT_ID), sentHtmlCaptor.capture(), isNull()); + verify(messageSender, atLeastOnce()).sendHtmlReliableAndGetId( + eq(CHAT_ID), sentHtmlCaptor.capture(), eq(USER_MSG_ID), eq(false), eq(5000L)); boolean deliveredSafetyText = sentHtmlCaptor.getAllValues().stream() .anyMatch(html -> html.contains("I reached the iteration limit")); assertThat(deliveredSafetyText) @@ -427,11 +439,10 @@ void shouldRenderSanitizedAnswerInTentativeBubbleOnFinalEdit() { actions.generateResponse(ctx); - // Collect all edit bodies applied to the answer bubble; the last one must contain - // the sanitized text and not the dead URL. + // The final answer send must contain the sanitized text and not the dead URL. ArgumentCaptor editCaptor = ArgumentCaptor.forClass(String.class); - verify(messageSender, atLeastOnce()) - .editHtml(eq(CHAT_ID), eq(ANSWER_MSG_ID), editCaptor.capture(), anyBoolean()); + verify(messageSender, atLeastOnce()).sendHtmlReliableAndGetId( + eq(CHAT_ID), editCaptor.capture(), eq(USER_MSG_ID), eq(false), eq(5000L)); String finalEdit = editCaptor.getAllValues().get(editCaptor.getAllValues().size() - 1); assertThat(finalEdit) @@ -471,7 +482,8 @@ void shouldSplitOversizedSingleParagraphWhenSendingFinalAnswer() { actions.generateResponse(ctx); ArgumentCaptor sentHtmlCaptor = ArgumentCaptor.forClass(String.class); - verify(messageSender, atLeastOnce()).sendHtml(eq(CHAT_ID), sentHtmlCaptor.capture(), isNull()); + verify(messageSender, atLeastOnce()).sendHtmlReliableAndGetId( + eq(CHAT_ID), sentHtmlCaptor.capture(), any(), eq(false), eq(5000L)); assertThat(sentHtmlCaptor.getAllValues()) .as("oversized single paragraph must be split into multiple chunks") diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java deleted file mode 100644 index e1b032ff..00000000 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/fsm/TelegramMessageHandlerActionsTentativeEditTest.java +++ /dev/null @@ -1,213 +0,0 @@ -package io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.github.ngirchev.opendaimon.common.agent.AgentExecutor; -import io.github.ngirchev.opendaimon.common.ai.pipeline.AIRequestPipeline; -import io.github.ngirchev.opendaimon.common.service.AIGatewayRegistry; -import io.github.ngirchev.opendaimon.common.service.OpenDaimonMessageService; -import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; -import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; -import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService; -import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService; -import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamRenderer; -import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageService; -import io.github.ngirchev.opendaimon.telegram.service.TelegramUserService; -import io.github.ngirchev.opendaimon.telegram.service.TelegramUserSessionService; -import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.lang.reflect.Method; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -/** - * Focused regression test for the buggy unconditional second {@code editHtml} in - * {@link TelegramMessageHandlerActions#editTentativeAnswer(MessageHandlerContext, boolean)}. - * - *

Before the fix Telegram answered with - * {@code TelegramApiRequestException: [400] Bad Request: text must be non-empty} whenever - * the tentative-answer buffer was empty or whitespace-only at flush time — typically right - * after a rotation that left only a newline in the tail. The log line - * {@code FSM agentStream: tentative answer bubble send failed — staying in STATUS_ONLY} - * followed. - * - *

The method is private; the FSM public entry point would require a full agent stream - * to reach the specific edge case. Reflection is used to hit the method with a pre-seeded - * context so the test stays deterministic and narrowly scoped to the guard. - */ -@ExtendWith(MockitoExtension.class) -class TelegramMessageHandlerActionsTentativeEditTest { - - private static final int MAX_ITERATIONS = 5; - private static final Long CHAT_ID = 12345L; - private static final int USER_MSG_ID = 100; - private static final int ANSWER_MSG_ID = 777; - - @Mock private TelegramUserService telegramUserService; - @Mock private TelegramUserSessionService telegramUserSessionService; - @Mock private TelegramMessageService telegramMessageService; - @Mock private AIGatewayRegistry aiGatewayRegistry; - @Mock private OpenDaimonMessageService messageService; - @Mock private AIRequestPipeline aiRequestPipeline; - @Mock private ChatSettingsService chatSettingsService; - @Mock private PersistentKeyboardService persistentKeyboardService; - @Mock private ReplyImageAttachmentService replyImageAttachmentService; - @Mock private TelegramMessageSender messageSender; - @Mock private AgentExecutor agentExecutor; - - private TelegramAgentStreamRenderer agentStreamRenderer; - private TelegramMessageHandlerActions actions; - private TelegramProperties telegramProperties; - - @BeforeEach - void setUp() { - telegramProperties = new TelegramProperties(); - telegramProperties.setMaxMessageLength(4096); - // No debounce — every forceFlush=false path would still flush on first call; here - // every test invokes forceFlush=true so the debounce is bypassed regardless. - telegramProperties.setAgentStreamEditMinIntervalMs(0); - agentStreamRenderer = new TelegramAgentStreamRenderer(new ObjectMapper()); - - actions = new TelegramMessageHandlerActions( - telegramUserService, telegramUserSessionService, - telegramMessageService, aiGatewayRegistry, messageService, - aiRequestPipeline, telegramProperties, chatSettingsService, - persistentKeyboardService, replyImageAttachmentService, messageSender, - agentExecutor, agentStreamRenderer, MAX_ITERATIONS, true); - } - - @Test - @DisplayName("should skip editHtml and leave lastAnswerEditAtMs untouched when tentative buffer is empty") - void shouldSkipEditHtmlWhenBufferIsEmpty() throws Exception { - MessageHandlerContext ctx = newContext(); - ctx.setTentativeAnswerMessageId(ANSWER_MSG_ID); - // Buffer untouched: length == 0 → renderTentativeBuffer produces empty HTML. - - invokeEditTentativeAnswer(ctx, /*forceFlush=*/ true); - - verifyNoInteractions(messageSender); - assertThat(ctx.getLastAnswerEditAtMs()) - .as("markAnswerEdited must NOT advance the debounce clock when nothing was sent") - .isZero(); - } - - @Test - @DisplayName("should skip editHtml when tentative buffer contains only whitespace") - void shouldSkipEditHtmlWhenBufferIsWhitespaceOnly() throws Exception { - MessageHandlerContext ctx = newContext(); - ctx.setTentativeAnswerMessageId(ANSWER_MSG_ID); - ctx.getTentativeAnswerBuffer().append("\n \n"); - - invokeEditTentativeAnswer(ctx, /*forceFlush=*/ true); - - verify(messageSender, never()).editHtml(any(), any(), anyString(), anyBoolean()); - verify(messageSender, never()).sendHtmlAndGetId(any(), anyString(), any(), anyBoolean()); - assertThat(ctx.getLastAnswerEditAtMs()).isZero(); - } - - @Test - @DisplayName("should skip the tail edit but still send the rotated head when rotation leaves a blank tail") - void shouldSkipTailEditButSendRotatedHeadWhenTailIsBlank() throws Exception { - // Tight budget so a short head triggers rotation; tail is whitespace only — the - // exact pathological shape the bug exposed: rotate → send fresh bubble for a blank - // tail → Telegram would then reject the unconditional follow-up edit. - telegramProperties.setMaxMessageLength(6); - - MessageHandlerContext ctx = newContext(); - ctx.setTentativeAnswerMessageId(ANSWER_MSG_ID); - // "Hello. " — 9 chars > maxLength=6. Window = "Hello." has no paragraph/sentence - // boundary and no whitespace, so the rotator falls back to a hard cut at 6. Head = - // "Hello.", tail = " " (three spaces) → renderTentativeBuffer returns blank HTML. - ctx.getTentativeAnswerBuffer().append("Hello. "); - - when(messageSender.sendHtmlAndGetId(eq(CHAT_ID), anyString(), isNull(), anyBoolean())) - .thenReturn(999); - - invokeEditTentativeAnswer(ctx, /*forceFlush=*/ true); - - // Head edit fires exactly once on the original bubble id. - verify(messageSender, times(1)).editHtml(eq(CHAT_ID), eq(ANSWER_MSG_ID), - anyString(), anyBoolean()); - // A fresh bubble is opened for the (still-blank) tail; this path predates the bug - // and is kept as-is — the reported failure was strictly on the follow-up edit. - verify(messageSender, times(1)).sendHtmlAndGetId(eq(CHAT_ID), anyString(), - isNull(), anyBoolean()); - // The bug-fix assertion: no second editHtml against the freshly-created bubble id - // (999) with a blank tail. Before the fix Telegram rejected this with - // "Bad Request: text must be non-empty". - verify(messageSender, never()).editHtml(eq(CHAT_ID), eq(999), anyString(), anyBoolean()); - assertThat(ctx.getLastAnswerEditAtMs()) - .as("no tail edit was sent, debounce clock must stay at 0") - .isZero(); - } - - @Test - @DisplayName("should editHtml and mark answer edited when buffer has real content") - void shouldEditHtmlAndMarkAnswerEditedWhenBufferHasContent() throws Exception { - MessageHandlerContext ctx = newContext(); - ctx.setTentativeAnswerMessageId(ANSWER_MSG_ID); - ctx.getTentativeAnswerBuffer().append("Partial answer so far."); - - invokeEditTentativeAnswer(ctx, /*forceFlush=*/ true); - - verify(messageSender, times(1)).editHtml(eq(CHAT_ID), eq(ANSWER_MSG_ID), - anyString(), anyBoolean()); - assertThat(ctx.getLastAnswerEditAtMs()) - .as("real content was sent, debounce clock must advance") - .isGreaterThan(0L); - } - - @Test - @DisplayName("should return immediately when tentativeAnswerMessageId is null") - void shouldReturnImmediatelyWhenMessageIdIsNull() throws Exception { - MessageHandlerContext ctx = newContext(); - // tentativeAnswerMessageId intentionally left null. - ctx.getTentativeAnswerBuffer().append("Some text that would otherwise render."); - - invokeEditTentativeAnswer(ctx, /*forceFlush=*/ true); - - verifyNoInteractions(messageSender); - } - - // ── Helpers ────────────────────────────────────────────────────────── - - private MessageHandlerContext newContext() { - TelegramCommand command = mock(TelegramCommand.class); - // Both stubs are lenient because the early-return branch (null tentativeAnswerMessageId) - // bypasses command.telegramId() entirely, making the stub unused in that specific test. - lenient().when(command.telegramId()).thenReturn(CHAT_ID); - org.telegram.telegrambots.meta.api.objects.Message message = - mock(org.telegram.telegrambots.meta.api.objects.Message.class); - lenient().when(message.getMessageId()).thenReturn(USER_MSG_ID); - return new MessageHandlerContext(command, message, s -> {}); - } - - /** - * Invokes the private {@code editTentativeAnswer} directly. Public entry would require - * driving an agent stream and relying on incidental rotation — too coarse for a guard - * regression test. - */ - private void invokeEditTentativeAnswer(MessageHandlerContext ctx, boolean forceFlush) throws Exception { - Method method = TelegramMessageHandlerActions.class.getDeclaredMethod( - "editTentativeAnswer", MessageHandlerContext.class, boolean.class); - method.setAccessible(true); - method.invoke(actions, ctx, forceFlush); - } -} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/InMemoryModelSelectionSessionTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/InMemoryModelSelectionSessionTest.java index 9093058e..1dac45ad 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/InMemoryModelSelectionSessionTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/InMemoryModelSelectionSessionTest.java @@ -7,7 +7,10 @@ import java.util.List; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; @@ -100,4 +103,40 @@ void shouldReturnDefensiveCopy() { // Assert — returned list should be immutable (List.copyOf) assertThat(result).isUnmodifiable(); } + + @Test + void shouldInvokeFetcherOnceUnderConcurrentRequestsForSameUser() throws InterruptedException { + // Reproducer for TD-future-A race: under non-atomic get()+put(), two threads observing + // the same cache miss would both invoke the (slow) fetcher. Atomic compute() single-flights it. + AtomicInteger fetcherCalls = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(2); + List models = List.of(new ModelInfo("gpt-4", Set.of(ModelCapabilities.CHAT), "openai")); + Supplier> slowFetcher = () -> { + fetcherCalls.incrementAndGet(); + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return models; + }; + Runnable task = () -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + session.getOrFetch(42L, slowFetcher); + done.countDown(); + }; + new Thread(task, "concurrent-fetcher-1").start(); + new Thread(task, "concurrent-fetcher-2").start(); + + start.countDown(); + + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(fetcherCalls.get()).isEqualTo(1); + } } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardServiceTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardServiceTest.java index ea889bc3..5da84d36 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardServiceTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/PersistentKeyboardServiceTest.java @@ -14,19 +14,23 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.support.ReloadableResourceBundleMessageSource; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMarkup; import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class PersistentKeyboardServiceTest { private static final long USER_ID = 1L; + private static final long GROUP_CHAT_ID = -5267226692L; @Mock private CoreCommonProperties coreCommonProperties; @@ -34,6 +38,12 @@ class PersistentKeyboardServiceTest { private CoreCommonProperties.SummarizationProperties summarizationProperties; @Mock private UserRepository userRepository; + @Mock + private TelegramChatPacer telegramChatPacer; + @Mock + private ObjectProvider botProvider; + @Mock + private TelegramBot telegramBot; private PersistentKeyboardService service; @@ -44,7 +54,6 @@ void setUp() { messageSource.setDefaultEncoding("UTF-8"); MessageLocalizationService messageLocalizationService = new MessageLocalizationService(messageSource); - ObjectProvider botProvider = mock(ObjectProvider.class); TelegramProperties telegramProperties = new TelegramProperties(); telegramProperties.setToken("t"); telegramProperties.setUsername("u"); @@ -54,6 +63,12 @@ void setUp() { when(coreCommonProperties.getSummarization()).thenReturn(summarizationProperties); when(summarizationProperties.getMessageWindowSize()).thenReturn(20); when(summarizationProperties.getMaxWindowTokens()).thenReturn(8000); + try { + lenient().when(telegramChatPacer.reserve(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } TelegramUser user = new TelegramUser(); user.setLanguageCode("en"); @@ -65,7 +80,8 @@ void setUp() { botProvider, telegramProperties, messageLocalizationService, - userRepository); + userRepository, + telegramChatPacer); } /** @@ -86,4 +102,25 @@ void buildKeyboardMarkup_doesNotSetIsPersistent_soUserCanDismissCustomKeyboard() Boolean.TRUE.equals(markup.getIsPersistent()), "ReplyKeyboardMarkup.is_persistent must stay false (default) for normal IME back behavior on Telegram Android"); } + + @Test + void sendKeyboard_waitsOneChatPacingIntervalAfterStreamBeforeSkipping() throws Exception { + ConversationThread thread = new ConversationThread(); + thread.setTotalMessages(8); + thread.setMessagesAtLastSummarization(0); + thread.setTotalTokens(0L); + when(summarizationProperties.getMessageWindowSize()).thenReturn(100); + when(botProvider.getObject()).thenReturn(telegramBot); + when(telegramChatPacer.intervalMs(GROUP_CHAT_ID)).thenReturn(3000L); + + service.sendKeyboard(GROUP_CHAT_ID, USER_ID, thread, "z-ai/glm-4.5v"); + + verify(telegramChatPacer).reserve(GROUP_CHAT_ID, 4000L); + org.mockito.ArgumentCaptor messageCaptor = + org.mockito.ArgumentCaptor.forClass(SendMessage.class); + verify(telegramBot).execute(messageCaptor.capture()); + SendMessage message = messageCaptor.getValue(); + assertEquals(Long.toString(GROUP_CHAT_ID), message.getChatId()); + assertEquals("🤖 z-ai/glm-4.5v · 💬 8%", message.getText()); + } } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModelTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModelTest.java new file mode 100644 index 00000000..f0888a46 --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModelTest.java @@ -0,0 +1,280 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import io.github.ngirchev.opendaimon.common.agent.AgentStreamEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TelegramAgentStreamModelTest { + + @Test + @DisplayName("should keep partial answer as status candidate until final answer confirms it") + void shouldKeepPartialAnswerAsStatusCandidateUntilFinalAnswerConfirmsIt() { + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + + model.apply(AgentStreamEvent.partialAnswer("Quick reply", 0)); + + assertThat(model.statusHtml()).contains("Quick reply"); + assertThat(model.hasConfirmedAnswer()).isFalse(); + + model.apply(AgentStreamEvent.finalAnswer("Quick reply", 0)); + + assertThat(model.hasConfirmedAnswer()).isTrue(); + assertThat(model.answerHtml()).contains("Quick reply"); + } + + @Test + @DisplayName("should fold pre-tool partial text into status and clear candidate when a tool call arrives") + void shouldFoldPreToolPartialTextIntoStatusAndClearCandidateWhenToolCallArrives() { + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, true); + + model.apply(AgentStreamEvent.partialAnswer("I should search first.", 0)); + model.apply(AgentStreamEvent.toolCall("web_search", "{\"query\":\"telegram limits\"}", 0)); + model.apply(AgentStreamEvent.observation("result body", 0)); + + assertThat(model.statusHtml()) + .contains("I should search first.") + .contains("🔧 Tool:") + .contains("telegram limits") + .contains("📋 Tool result received"); + assertThat(model.hasCandidateText()).isFalse(); + assertThat(model.hasConfirmedAnswer()).isFalse(); + assertThat(model.isToolCallSeenThisIteration()).isTrue(); + } + + @Test + @DisplayName("should clear trailing partial overlay from status when answer is confirmed") + void shouldClearTrailingPartialOverlayFromStatusWhenAnswerIsConfirmed() { + // Reproduces the "На ос" duplication bug: agent finishes a tool round, streams a + // partial of the final answer into the status overlay, then FINAL_ANSWER arrives. + // The status must not retain the partial fragment alongside the new answer message. + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + model.apply(AgentStreamEvent.thinking(0)); + model.apply(AgentStreamEvent.toolCall("web_search", "{\"query\":\"tickets\"}", 0)); + model.apply(AgentStreamEvent.observation("ok", 0)); + model.apply(AgentStreamEvent.thinking(1)); + model.apply(AgentStreamEvent.partialAnswer("На ос", 1)); + + assertThat(model.statusHtml()).contains("На ос"); + + model.apply(AgentStreamEvent.finalAnswer("На основе поиска…", 1)); + + assertThat(model.statusHtml()) + .as("partial overlay must be stripped once the answer is confirmed") + .doesNotContain("На ос") + .doesNotContain(""); + assertThat(model.isStatusDirty()) + .as("flushFinal must re-render the cleaned status to Telegram") + .isTrue(); + assertThat(model.answerHtml()).contains("На основе поиска"); + } + + @Test + @DisplayName("should keep history intact when only an overlay-free terminal arrives") + void shouldKeepHistoryIntactWhenOnlyAnOverlayFreeTerminalArrives() { + // No partial chunks were ever streamed in the final iteration — the trailing line + // is the "💭 Thinking..." marker, not an overlay. confirmAnswer must NOT touch it. + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + model.apply(AgentStreamEvent.thinking(0)); + model.apply(AgentStreamEvent.toolCall("web_search", "{\"query\":\"x\"}", 0)); + model.apply(AgentStreamEvent.observation("ok", 0)); + model.apply(AgentStreamEvent.thinking(1)); + + String beforeConfirm = model.statusHtml(); + model.apply(AgentStreamEvent.finalAnswer("Final answer", 1)); + + assertThat(model.statusHtml()) + .as("status without partial overlay must survive confirmation untouched") + .isEqualTo(beforeConfirm); + } + + @Test + @DisplayName("should not clear status when post-tool partial was never rendered as overlay") + void shouldNotClearStatusWhenPostToolPartialWasNeverRenderedAsOverlay() { + // Once a tool call was seen in the iteration, partial chunks are no longer + // rendered as a status overlay. The terminal cleanup must therefore keep the + // completed tool and observation transcript intact. + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + model.apply(AgentStreamEvent.toolCall("web_search", "{\"query\":\"x\"}", 0)); + model.apply(AgentStreamEvent.observation("ok", 0)); + model.apply(AgentStreamEvent.partialAnswer("Final after tool", 0)); + + String beforeConfirm = model.statusHtml(); + assertThat(beforeConfirm) + .contains("🔧 Tool:") + .contains("📋 Tool result received") + .doesNotContain("Final after tool"); + + model.apply(AgentStreamEvent.finalAnswer("Final after tool", 0)); + + assertThat(model.statusHtml()).isEqualTo(beforeConfirm); + assertThat(model.answerHtml()).contains("Final after tool"); + } + + @Test + @DisplayName("should leave completion marker when status was entirely overlay") + void shouldLeaveCompletionMarkerWhenStatusWasEntirelyOverlay() { + // First-iteration straight-to-answer: partial chunk overwrites the initial + // "💭 Thinking..." line, then FINAL arrives. Stripping leaves an empty status — + // Telegram rejects empty edits, so the model substitutes a "✅" marker. + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + model.apply(AgentStreamEvent.partialAnswer("Quick", 0)); + + assertThat(model.statusHtml()).contains("Quick"); + + model.apply(AgentStreamEvent.finalAnswer("Quick reply", 0)); + + assertThat(model.statusHtml()) + .doesNotContain("Quick") + .isEqualTo("✅"); + } + + @Test + @DisplayName("should treat same event sequence provider-neutrally for OpenRouter and Ollama") + void shouldTreatSameEventSequenceProviderNeutrallyForOpenRouterAndOllama() { + TelegramAgentStreamModel openRouter = replayProviderNeutralSequence(); + TelegramAgentStreamModel ollama = replayProviderNeutralSequence(); + + assertThat(openRouter.statusHtml()).isEqualTo(ollama.statusHtml()); + assertThat(openRouter.answerHtml()).isEqualTo(ollama.answerHtml()); + } + + private TelegramAgentStreamModel replayProviderNeutralSequence() { + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + model.apply(AgentStreamEvent.thinking(0)); + model.apply(AgentStreamEvent.partialAnswer("Need a tool.", 0)); + model.apply(AgentStreamEvent.toolCall("web_search", "{\"query\":\"x\"}", 0)); + model.apply(AgentStreamEvent.observation("ok", 0)); + model.apply(AgentStreamEvent.thinking(1)); + model.apply(AgentStreamEvent.partialAnswer("Final text", 1)); + model.apply(AgentStreamEvent.finalAnswer("Final text", 1)); + return model; + } + + @Test + @DisplayName("should render bold markdown inside the partial-answer overlay") + void shouldRenderBoldMarkdownInPartialOverlay() { + // Reproducer: in production a partial chunk like "...платформа - **SoldOut Tickets**..." + // surfaced in the status overlay with literal asterisks because TelegramHtmlEscaper + // only escapes <, >, & and leaves * untouched. The overlay must run the escaped + // text through AIUtils.convertEscapedMarkdownToHtml so **bold** becomes bold. + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + + model.apply(AgentStreamEvent.partialAnswer("Платформа - **SoldOut Tickets** работает", 0)); + + assertThat(model.statusHtml()) + .contains("SoldOut Tickets") + .doesNotContain("**SoldOut"); + } + + @Test + @DisplayName("should not orphan markdown markers when overlay tail is truncated mid-pair") + void shouldNotOrphanMarkdownMarkersWhenTailIsTruncated() { + // When candidateEscaped exceeds CANDIDATE_TAIL_LIMIT (400) and the raw cut would land + // inside a `**bold**` pair, the orphan `**` survives the markdown regex. The overlay + // must shift the cut forward to the next word boundary so no half-pair leaks through. + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + String filler = "А".repeat(390); + + model.apply(AgentStreamEvent.partialAnswer(filler + " **SoldOut Tickets** хвост", 0)); + + assertThat(model.statusHtml()).doesNotContain("**"); + } + + @Test + @DisplayName("should strip stuck overlay even when partial chunk left orphan markdown at the tail") + void shouldStripStuckOverlayWhenLastChunkLeftOrphanMarkdown() { + // Reproducer for the screenshot bug at 23:24: PARTIAL_ANSWER chunks accumulated past + // CANDIDATE_TAIL_LIMIT and ended mid-`**bold` pair, so the overlay's recomputation + // could diverge from what was last written to statusHtml. Under the old strict + // endsWith check this caused clearTrailingPartialOverlay to skip — leaving the + // italic bubble frozen next to the polished final answer. Strip must run regardless. + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + model.apply(AgentStreamEvent.thinking(0)); + model.apply(AgentStreamEvent.toolCall("web_search", "{\"query\":\"x\"}", 0)); + model.apply(AgentStreamEvent.observation("ok", 0)); + model.apply(AgentStreamEvent.thinking(1)); + String filler = "слово ".repeat(80); + model.apply(AgentStreamEvent.partialAnswer( + filler + "Партнерская платформа - **Другие способы", 1)); + + assertThat(model.statusHtml()).contains(""); + + model.apply(AgentStreamEvent.finalAnswer("Final cleaned answer", 1)); + + assertThat(model.statusHtml()) + .as("partial overlay must be stripped from the status bubble even when the tail had orphan markdown") + .doesNotContain("") + .doesNotContain("Другие способы"); + } + + @Test + @DisplayName("should strip final partial overlay before appending max-iterations marker") + void shouldStripFinalPartialOverlayBeforeMaxIterationsMarker() { + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + model.apply(AgentStreamEvent.toolCall("web_search", "{\"query\":\"x\"}", 0)); + model.apply(AgentStreamEvent.observation("ok", 0)); + model.apply(AgentStreamEvent.thinking(1)); + model.apply(AgentStreamEvent.partialAnswer("Final answer leaked into status", 1)); + + assertThat(model.statusHtml()).contains("Final answer leaked into status"); + + model.apply(AgentStreamEvent.maxIterations("Final answer leaked into status", 1)); + + assertThat(model.statusHtml()) + .contains(TelegramAgentStreamModel.STATUS_MAX_ITER_LINE) + .doesNotContain("Final answer leaked into status"); + assertThat(model.answerHtml()).contains("Final answer leaked into status"); + } + + @Test + @DisplayName("should remove trailing reasoning overlay on final answer when reasoning is hidden") + void shouldRemoveTrailingReasoningOverlayOnFinalAnswerWhenReasoningIsHidden() { + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + + model.apply(AgentStreamEvent.thinking("Final text emitted as reasoning", 0)); + model.apply(AgentStreamEvent.finalAnswer("Final text emitted as reasoning", 0)); + + assertThat(model.statusHtml()).doesNotContain("Final text emitted as reasoning"); + assertThat(model.answerHtml()).contains("Final text emitted as reasoning"); + } + + @Test + @DisplayName("should preserve trailing reasoning overlay on final answer when SHOW_ALL is enabled") + void shouldPreserveTrailingReasoningOverlayOnFinalAnswerWhenShowAllIsEnabled() { + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, true); + + model.apply(AgentStreamEvent.thinking("I checked sources before answering.", 0)); + model.apply(AgentStreamEvent.finalAnswer("Final answer.", 0)); + + assertThat(model.statusHtml()).contains("I checked sources before answering."); + assertThat(model.answerHtml()).contains("Final answer."); + } + + @Test + @DisplayName("should render empty tool arguments as missing query") + void shouldRenderEmptyToolArgumentsAsMissingQuery() { + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + + model.apply(AgentStreamEvent.toolCall("web_search", "{}", 0)); + + assertThat(model.statusHtml()) + .contains("Query: missing") + .doesNotContain("Query: …"); + } + + @Test + @DisplayName("should start overlay tail on a word boundary, not in the middle of a word") + void shouldStartOverlayTailOnWordBoundary() { + // Reproducer for the visible "ае платформа..." regression: the raw byte cut at + // length-400 landed inside «универсальная», leaving a "ае" fragment. The fix walks + // the cut forward to the next whitespace so the overlay always starts on a whole word. + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + String filler = "слово ".repeat(80); + + model.apply(AgentStreamEvent.partialAnswer(filler + "финал", 0)); + + assertThat(model.statusHtml()).doesNotContainPattern("(?:ло|ов)во "); + } +} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamViewConcurrencyTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamViewConcurrencyTest.java new file mode 100644 index 00000000..30607ae9 --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamViewConcurrencyTest.java @@ -0,0 +1,243 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import io.github.ngirchev.opendaimon.common.agent.AgentStreamEvent; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageSender; +import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.telegram.telegrambots.meta.api.objects.Message; + +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Regression coverage for TD-1 (`docs/team/td-1-stream-view-state-isolation.md`). + * + *

Before TD-1 the singleton {@link TelegramAgentStreamView} held a mutable + * {@code int statusRenderedOffset} field shared across all chats — concurrent flushes + * leaked state between contexts. This test pins the post-fix invariant: per-stream + * render offset lives on each {@link MessageHandlerContext}, so two threads flushing + * the same View instance with two distinct contexts produce per-context offsets that + * reflect ONLY their own model state. + * + *

Setup follows the §7 LOW-row mitigation: {@code CyclicBarrier(2)} rendezvous + * forces both threads to enter the critical section before either proceeds (stronger + * contention guarantee than {@code CountDownLatch(1)}); a JUnit {@code @Timeout(5s)} + * fails loud if the production code regresses to a deadlock instead of hanging the CI. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TelegramAgentStreamViewConcurrencyTest { + + private static final long CHAT_ID_A = 100L; + private static final long CHAT_ID_B = 200L; + private static final int STATUS_MSG_ID_A = 5001; + private static final int STATUS_MSG_ID_B = 5002; + private static final int ROTATED_NEW_MSG_ID_B = 5003; + /** + * Tight cap that triggers rotation for the long context's status HTML but never + * for the short one. Picked well below Telegram's real 4096 to keep test data tiny. + */ + private static final int MAX_MESSAGE_LENGTH = 60; + + @Mock private TelegramMessageSender messageSender; + @Mock private TelegramChatPacer telegramChatPacer; + + private TelegramProperties telegramProperties; + private TelegramAgentStreamView view; + private ExecutorService executor; + + /** + * Covers: REQ-1 (View statelessness). + * Reflection-based invariant: {@link TelegramAgentStreamView} declares only + * {@code final} instance fields. Guards against a future contributor re-introducing + * mutable singleton state — direct §7 MEDIUM-risk mitigation. + */ + @BeforeAll + static void shouldDeclareOnlyFinalInstanceFields() { + boolean allFinal = Arrays.stream(TelegramAgentStreamView.class.getDeclaredFields()) + .filter(f -> !Modifier.isStatic(f.getModifiers())) + .allMatch(f -> Modifier.isFinal(f.getModifiers())); + assertThat(allFinal) + .as("REQ-1: every instance field on TelegramAgentStreamView must be final " + + "— mutable singleton state is exactly the TD-1 anti-pattern") + .isTrue(); + } + + @BeforeEach + void setUp() throws InterruptedException { + telegramProperties = new TelegramProperties(); + telegramProperties.setMaxMessageLength(MAX_MESSAGE_LENGTH); + telegramProperties.setAgentStreamEditMinIntervalMs(0); + + // Pacing slot is always available so flushStatus reaches the offset-mutating branches. + lenient().when(telegramChatPacer.tryReserve(anyLong())).thenReturn(true); + lenient().when(telegramChatPacer.reserve(anyLong(), anyLong())).thenReturn(true); + + // After rotation, flushStatus sends the tail as a fresh message and adopts its id. + // Only context B should reach this branch; stub for both chats to keep the mock generic. + lenient().when(messageSender.sendHtmlAndGetId(eq(CHAT_ID_B), anyString(), any(), anyBoolean())) + .thenReturn(ROTATED_NEW_MSG_ID_B); + lenient().when(messageSender.sendHtmlAndGetId(eq(CHAT_ID_A), anyString(), any(), anyBoolean())) + .thenReturn(STATUS_MSG_ID_A); + lenient().when(messageSender.editHtmlReliable(anyLong(), any(), anyString(), anyBoolean(), anyLong())) + .thenReturn(true); + lenient().when(messageSender.sendHtmlReliableAndGetId(eq(CHAT_ID_B), anyString(), any(), anyBoolean(), anyLong())) + .thenReturn(ROTATED_NEW_MSG_ID_B); + + view = new TelegramAgentStreamView(messageSender, telegramChatPacer, telegramProperties); + executor = Executors.newFixedThreadPool(2); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + /** + * Covers: REQ-3 (concurrency isolation). + * + *

Two threads simultaneously call {@code view.flush(ctx, model, true)} — one with a + * SHORT status HTML (no rotation, expected offset stays 0) and one with a LONG status + * HTML that exceeds {@link #MAX_MESSAGE_LENGTH} (rotation triggers, expected offset is + * a strictly positive value reflecting the truncated head). Under the pre-TD-1 code, + * the singleton field would have ended with a single value (whichever thread wrote + * last), so the two contexts would necessarily read the same offset. With TD-1 + * applied, each context retains its own — the assertion of distinct offsets is the + * exact regression guard. + * + *

The {@code CyclicBarrier(2)} rendezvous maximises contention on the View; the + * JUnit {@code @Timeout(5s)} fails loud rather than hanging the CI if the production + * code regresses to a deadlock or infinite loop. + */ + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("REQ-3: concurrent flushes with two contexts must keep statusRenderedOffset isolated per context") + void shouldKeepStatusRenderedOffsetIsolatedAcrossConcurrentFlushes() throws Exception { + // ── ctx A: short status HTML, status bubble already sent (statusMessageId pre-set). + // Edit branch runs but no rotation → offset must remain 0. + MessageHandlerContext ctxA = newContext(CHAT_ID_A); + ctxA.setStatusMessageId(STATUS_MSG_ID_A); + TelegramAgentStreamModel modelA = new TelegramAgentStreamModel(false, false); + // Constructor seeds "💭 Thinking..." (~16 chars) — well under MAX_MESSAGE_LENGTH=60. + // Force statusDirty so flushStatus actually does work. + modelA.apply(AgentStreamEvent.thinking(0)); + int statusLengthA = modelA.statusHtml().length(); + assertThat(statusLengthA) + .as("test precondition: ctxA status HTML must NOT exceed maxMessageLength") + .isLessThanOrEqualTo(MAX_MESSAGE_LENGTH); + + // ── ctx B: long status HTML, status bubble already sent. Will trigger rotation + // inside flushStatus → setStatusRenderedOffset(fullHtml.length() - tail.length()). + MessageHandlerContext ctxB = newContext(CHAT_ID_B); + ctxB.setStatusMessageId(STATUS_MSG_ID_B); + TelegramAgentStreamModel modelB = new TelegramAgentStreamModel(false, false); + // Build status HTML well past MAX_MESSAGE_LENGTH=60 so rotation fires. + // Each tool-call+observation pair appends a multi-line block. + modelB.apply(AgentStreamEvent.thinking(0)); + modelB.apply(AgentStreamEvent.toolCall("web_search", "{\"q\":\"alpha-bravo-charlie\"}", 0)); + modelB.apply(AgentStreamEvent.observation("first observation payload data", false, 0)); + modelB.apply(AgentStreamEvent.thinking(1)); + modelB.apply(AgentStreamEvent.toolCall("web_search", "{\"q\":\"delta-echo-foxtrot\"}", 1)); + modelB.apply(AgentStreamEvent.observation("second observation payload data", false, 1)); + int statusLengthB = modelB.statusHtml().length(); + assertThat(statusLengthB) + .as("test precondition: ctxB status HTML MUST exceed maxMessageLength so rotation fires") + .isGreaterThan(MAX_MESSAGE_LENGTH); + + // ── Concurrent rendezvous: both threads call view.flush at the same instant. + CyclicBarrier barrier = new CyclicBarrier(2); + Future futureA = executor.submit(() -> { + await(barrier); + view.flush(ctxA, modelA, true); + }); + Future futureB = executor.submit(() -> { + await(barrier); + view.flush(ctxB, modelB, true); + }); + + // Surface any thrown exception from the worker threads. + futureA.get(4, TimeUnit.SECONDS); + futureB.get(4, TimeUnit.SECONDS); + + // ── REQ-3 assertions: each context must retain its OWN per-stream offset, + // consistent with its OWN model. Under the pre-TD-1 singleton-field code, + // both contexts would have read the same value (whichever thread wrote last), + // so this pair of assertions could not have held simultaneously. + assertThat(ctxA.getStatusRenderedOffset()) + .as("ctxA: short HTML did not trigger rotation → offset must stay at default 0") + .isZero(); + + assertThat(ctxB.getStatusRenderedOffset()) + .as("ctxB: long HTML triggered rotation → offset must be the head length " + + "(fullHtml.length() - tail.length())") + .isPositive() + .isLessThan(statusLengthB); + + assertThat(ctxA.getStatusRenderedOffset()) + .as("REQ-3 isolation: ctxA's offset must NOT have been overwritten by ctxB's " + + "rotation — proves the field lives on the per-request context, not the singleton") + .isNotEqualTo(ctxB.getStatusRenderedOffset()); + } + + private MessageHandlerContext newContext(long chatId) { + TelegramCommand command = mock(TelegramCommand.class); + when(command.telegramId()).thenReturn(chatId); + Message message = mock(Message.class); + // Use distinct reply-to ids to keep the two contexts visually distinct in failure dumps. + when(message.getMessageId()).thenReturn((int) chatId); + return new MessageHandlerContext(command, message, s -> {}); + } + + private static void await(CyclicBarrier barrier) { + try { + barrier.await(2, TimeUnit.SECONDS); + } catch (Exception e) { + throw new IllegalStateException("rendezvous barrier failed", e); + } + } + + /** + * Self-check: the reflection-based REQ-1 assertion above scans + * {@link TelegramAgentStreamView}'s declared fields. This test pins the assumption + * that the class actually declares some instance fields (otherwise the {@code allMatch} + * predicate vacuously returns {@code true} and the guard becomes a tautology). + */ + @Test + @DisplayName("REQ-1 self-check: TelegramAgentStreamView declares >0 instance fields (so allMatch isn't vacuous)") + void shouldExposeAtLeastOneInstanceFieldForTheReq1Guard() { + long instanceFields = Arrays.stream(TelegramAgentStreamView.class.getDeclaredFields()) + .filter(f -> !Modifier.isStatic(f.getModifiers())) + .count(); + assertThat(instanceFields) + .as("the REQ-1 reflection guard would be vacuous if the class had no instance fields") + .isPositive(); + } + +} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamViewTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamViewTest.java new file mode 100644 index 00000000..a4b99d15 --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamViewTest.java @@ -0,0 +1,109 @@ +package io.github.ngirchev.opendaimon.telegram.service; + +import io.github.ngirchev.opendaimon.common.agent.AgentStreamEvent; +import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext; +import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageSender; +import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.telegram.telegrambots.meta.api.objects.Message; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TelegramAgentStreamViewTest { + + private static final long CHAT_ID = 12345L; + private static final int USER_MESSAGE_ID = 10; + private static final int STATUS_MESSAGE_ID = 20; + private static final int ANSWER_MESSAGE_ID = 30; + + @Mock private TelegramMessageSender messageSender; + @Mock private TelegramChatPacer telegramChatPacer; + + private TelegramAgentStreamView view; + + @BeforeEach + void setUp() throws InterruptedException { + TelegramProperties properties = new TelegramProperties(); + properties.setMaxMessageLength(4096); + properties.getAgentStreamView().setFinalDeliveryTimeoutMs(5000); + lenient().when(telegramChatPacer.tryReserve(anyLong())).thenReturn(true); + lenient().when(telegramChatPacer.reserve(anyLong(), anyLong())).thenReturn(true); + view = new TelegramAgentStreamView(messageSender, telegramChatPacer, properties); + } + + @Test + @DisplayName("flushFinal should reliably edit cleaned status before sending final answer") + void shouldReliablyEditCleanedStatusBeforeSendingFinalAnswer() { + MessageHandlerContext ctx = newContext(); + ctx.setStatusMessageId(STATUS_MESSAGE_ID); + TelegramAgentStreamModel model = modelWithCleanedFinalAnswer(); + when(messageSender.editHtmlReliable(eq(CHAT_ID), eq(STATUS_MESSAGE_ID), any(), eq(true), eq(5000L))) + .thenReturn(true); + when(messageSender.sendHtmlReliableAndGetId(eq(CHAT_ID), any(), eq(USER_MESSAGE_ID), eq(false), eq(5000L))) + .thenReturn(ANSWER_MESSAGE_ID); + + boolean delivered = view.flushFinal(ctx, model); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(String.class); + verify(messageSender).editHtmlReliable( + eq(CHAT_ID), eq(STATUS_MESSAGE_ID), statusCaptor.capture(), eq(true), eq(5000L)); + assertThat(statusCaptor.getValue()) + .contains("🔧 Tool:") + .doesNotContain("Final answer leaked into status"); + verify(messageSender, never()).editHtml(eq(CHAT_ID), eq(STATUS_MESSAGE_ID), any(), eq(true)); + assertThat(delivered).isTrue(); + } + + @Test + @DisplayName("flushFinal should delete stale status when final status edit fails") + void shouldDeleteStaleStatusWhenFinalStatusEditFails() { + MessageHandlerContext ctx = newContext(); + ctx.setStatusMessageId(STATUS_MESSAGE_ID); + TelegramAgentStreamModel model = modelWithCleanedFinalAnswer(); + when(messageSender.editHtmlReliable(eq(CHAT_ID), eq(STATUS_MESSAGE_ID), any(), eq(true), eq(5000L))) + .thenReturn(false); + when(messageSender.deleteMessage(eq(CHAT_ID), eq(STATUS_MESSAGE_ID))).thenReturn(true); + when(messageSender.sendHtmlReliableAndGetId(eq(CHAT_ID), any(), eq(USER_MESSAGE_ID), eq(false), eq(5000L))) + .thenReturn(ANSWER_MESSAGE_ID); + + boolean delivered = view.flushFinal(ctx, model); + + verify(messageSender).deleteMessage(CHAT_ID, STATUS_MESSAGE_ID); + assertThat(ctx.getStatusMessageId()).isNull(); + assertThat(delivered).isTrue(); + } + + private static TelegramAgentStreamModel modelWithCleanedFinalAnswer() { + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + model.apply(AgentStreamEvent.toolCall("web_search", "{\"query\":\"tickets\"}", 0)); + model.apply(AgentStreamEvent.observation("ok", 0)); + model.apply(AgentStreamEvent.thinking(1)); + model.apply(AgentStreamEvent.partialAnswer("Final answer leaked into status", 1)); + model.apply(AgentStreamEvent.finalAnswer("Final answer leaked into status", 1)); + return model; + } + + private static MessageHandlerContext newContext() { + TelegramCommand command = mock(TelegramCommand.class); + when(command.telegramId()).thenReturn(CHAT_ID); + Message message = mock(Message.class); + when(message.getMessageId()).thenReturn(USER_MESSAGE_ID); + return new MessageHandlerContext(command, message, ignored -> {}); + } +}