mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat: add follow-up task suggestions (#102422)
* feat(tools): add follow-up task suggestions * chore: leave changelog to release flow * fix(tools): add task suggestion display metadata * fix(tools): update task suggestion display snapshot * docs: format task suggestion tool table * test(gateway): expect compaction worktree workspace * test(gateway): preserve configured compaction workspace * fix(ui): translate task suggestions
This commit is contained in:
parent
acac359de6
commit
5533d979d4
93 changed files with 2895 additions and 128 deletions
|
|
@ -409,6 +409,23 @@
|
|||
"plan.0.step"
|
||||
]
|
||||
},
|
||||
"spawn_task": {
|
||||
"emoji": "✨",
|
||||
"title": "Suggest Task",
|
||||
"detailKeys": [
|
||||
"title",
|
||||
"tldr",
|
||||
"cwd"
|
||||
]
|
||||
},
|
||||
"dismiss_task": {
|
||||
"emoji": "🗑️",
|
||||
"title": "Dismiss Task",
|
||||
"detailKeys": [
|
||||
"task_id",
|
||||
"reason"
|
||||
]
|
||||
},
|
||||
"skill_workshop": {
|
||||
"emoji": "🧰",
|
||||
"title": "Skill Workshop",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,12 @@ public enum SessionFileRelevance: String, Codable, Sendable {
|
|||
case mixed = "mixed"
|
||||
}
|
||||
|
||||
public enum TaskSuggestionResolution: String, Codable, Sendable {
|
||||
case dismissed = "dismissed"
|
||||
case accepted = "accepted"
|
||||
case expired = "expired"
|
||||
}
|
||||
|
||||
public struct ConnectParams: Codable, Sendable {
|
||||
public let minprotocol: Int
|
||||
public let maxprotocol: Int
|
||||
|
|
@ -2602,6 +2608,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
|||
public let task: String?
|
||||
public let message: String?
|
||||
public let worktree: Bool?
|
||||
public let cwd: String?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
|
|
@ -2613,7 +2620,8 @@ public struct SessionsCreateParams: Codable, Sendable {
|
|||
emitcommandhooks: Bool?,
|
||||
task: String?,
|
||||
message: String?,
|
||||
worktree: Bool?)
|
||||
worktree: Bool?,
|
||||
cwd: String?)
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
|
|
@ -2625,6 +2633,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
|||
self.task = task
|
||||
self.message = message
|
||||
self.worktree = worktree
|
||||
self.cwd = cwd
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
|
@ -2638,6 +2647,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
|||
case task
|
||||
case message
|
||||
case worktree
|
||||
case cwd
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3231,6 +3241,200 @@ public struct AuditListResult: Codable, Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
public struct TaskSuggestion: Codable, Sendable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let prompt: String
|
||||
public let tldr: String
|
||||
public let cwd: String
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let createdat: Int
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
title: String,
|
||||
prompt: String,
|
||||
tldr: String,
|
||||
cwd: String,
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
createdat: Int)
|
||||
{
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.prompt = prompt
|
||||
self.tldr = tldr
|
||||
self.cwd = cwd
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.createdat = createdat
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case prompt
|
||||
case tldr
|
||||
case cwd
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case createdat = "createdAt"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TaskSuggestionsAcceptParams: Codable, Sendable {
|
||||
public let taskid: String
|
||||
|
||||
public init(
|
||||
taskid: String)
|
||||
{
|
||||
self.taskid = taskid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case taskid = "taskId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TaskSuggestionsAcceptResult: Codable, Sendable {
|
||||
public let taskid: String
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
taskid: String,
|
||||
key: String)
|
||||
{
|
||||
self.taskid = taskid
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case taskid = "taskId"
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct TaskSuggestionsCreateParams: Codable, Sendable {
|
||||
public let title: String
|
||||
public let prompt: String
|
||||
public let tldr: String
|
||||
public let cwd: String
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
prompt: String,
|
||||
tldr: String,
|
||||
cwd: String,
|
||||
sessionkey: String,
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.title = title
|
||||
self.prompt = prompt
|
||||
self.tldr = tldr
|
||||
self.cwd = cwd
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case title
|
||||
case prompt
|
||||
case tldr
|
||||
case cwd
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TaskSuggestionsCreateResult: Codable, Sendable {
|
||||
public let taskid: String
|
||||
public let suggestion: TaskSuggestion
|
||||
|
||||
public init(
|
||||
taskid: String,
|
||||
suggestion: TaskSuggestion)
|
||||
{
|
||||
self.taskid = taskid
|
||||
self.suggestion = suggestion
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case taskid = "taskId"
|
||||
case suggestion
|
||||
}
|
||||
}
|
||||
|
||||
public struct TaskSuggestionsDismissParams: Codable, Sendable {
|
||||
public let taskid: String
|
||||
public let reason: String?
|
||||
|
||||
public init(
|
||||
taskid: String,
|
||||
reason: String?)
|
||||
{
|
||||
self.taskid = taskid
|
||||
self.reason = reason
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case taskid = "taskId"
|
||||
case reason
|
||||
}
|
||||
}
|
||||
|
||||
public struct TaskSuggestionsDismissResult: Codable, Sendable {
|
||||
public let taskid: String
|
||||
public let dismissed: Bool
|
||||
|
||||
public init(
|
||||
taskid: String,
|
||||
dismissed: Bool)
|
||||
{
|
||||
self.taskid = taskid
|
||||
self.dismissed = dismissed
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case taskid = "taskId"
|
||||
case dismissed
|
||||
}
|
||||
}
|
||||
|
||||
public struct TaskSuggestionsListParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String?,
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TaskSuggestionsListResult: Codable, Sendable {
|
||||
public let suggestions: [TaskSuggestion]
|
||||
|
||||
public init(
|
||||
suggestions: [TaskSuggestion])
|
||||
{
|
||||
self.suggestions = suggestions
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case suggestions
|
||||
}
|
||||
}
|
||||
|
||||
public struct TaskSummary: Codable, Sendable {
|
||||
public let id: String
|
||||
public let kind: String?
|
||||
|
|
|
|||
|
|
@ -47,8 +47,12 @@ A nonzero exit aborts creation and removes the new worktree and branch. This is
|
|||
|
||||
Start an isolated chat from the active agent's git workspace with **New chat in worktree**: use the secondary New Chat action in the Control UI sidebar, the Chat actions menu on iOS, or the overflow action beside New Chat on Android. The action is available only for a git-backed agent where the client has that capability; clients that cannot preflight it surface the gateway error instead.
|
||||
|
||||
Coding agents can also call `spawn_task` when they discover confirmed follow-up work outside the current task. The Control UI shows a suggestion chip without starting anything. Selecting **Start in worktree** creates a fresh session-owned worktree from the suggested project and sends the self-contained prompt as its first turn; dismissing the chip leaves the repository untouched. Suggestions and their IDs are ephemeral and do not survive a Gateway restart.
|
||||
|
||||
The resulting managed worktree is owned by the session, and every agent run in that session uses its checkout. When the workspace is a repository subdirectory, the worktree is anchored at the repository root and the session runs from the matching subdirectory inside it. Session worktree creation uses the method's `operator.write` scope, but the `.openclaw/worktree-setup.sh` step runs only for `operator.admin` callers because it executes repository code; `.worktreeinclude` provisioning still applies to every caller. Deleting the session removes the worktree only when doing so is lossless. Dirty worktrees or branches with unpushed commits stay available; hourly cleanup snapshots session worktrees after 7 idle days, treating recent session activity as worktree activity. Removed worktrees remain restorable from their snapshots as described below.
|
||||
|
||||
`sessions.create` may include an absolute `cwd` together with `worktree: true` when a task targets a project other than the configured agent workspace. That explicit host path requires `operator.admin`; ordinary worktree chat creation remains `operator.write` and stays anchored to the configured workspace.
|
||||
|
||||
## Snapshots, cleanup, and restore
|
||||
|
||||
Removal first creates a synthetic commit containing tracked and non-ignored untracked files, and pins it at `refs/openclaw/snapshots/<id>`. Gitignored files are excluded from the repository object database; files selected by `.worktreeinclude` are copied again during restore. If snapshot creation fails, removal stops. An explicit force delete can continue without a snapshot.
|
||||
|
|
|
|||
|
|
@ -31,21 +31,23 @@ Local onboarding defaults new local configs to `tools.profile: "coding"` when un
|
|||
|
||||
### Tool groups
|
||||
|
||||
| Group | Tools |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `group:runtime` | `exec`, `process`, `code_execution` (`bash` is accepted as an alias for `exec`) |
|
||||
| `group:fs` | `read`, `write`, `edit`, `apply_patch` |
|
||||
| `group:sessions` | `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `sessions_yield`, `subagents`, `session_status` |
|
||||
| `group:memory` | `memory_search`, `memory_get` |
|
||||
| `group:web` | `web_search`, `x_search`, `web_fetch` |
|
||||
| `group:ui` | `browser`, `canvas` |
|
||||
| `group:automation` | `heartbeat_respond`, `cron`, `gateway` |
|
||||
| `group:messaging` | `message` |
|
||||
| `group:nodes` | `nodes` |
|
||||
| `group:agents` | `agents_list`, `get_goal`, `create_goal`, `update_goal`, `update_plan`, `skill_workshop` |
|
||||
| `group:media` | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` |
|
||||
| `group:openclaw` | All built-in tools above except `read`/`write`/`edit`/`apply_patch`/`exec`/`process`/`canvas` (excludes plugin tools) |
|
||||
| `group:plugins` | Tools owned by loaded plugins, including configured MCP servers exposed through `bundle-mcp` |
|
||||
| Group | Tools |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `group:runtime` | `exec`, `process`, `code_execution` (`bash` is accepted as an alias for `exec`) |
|
||||
| `group:fs` | `read`, `write`, `edit`, `apply_patch` |
|
||||
| `group:sessions` | `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `sessions_yield`, `subagents`, `session_status`, `spawn_task`, `dismiss_task` |
|
||||
| `group:memory` | `memory_search`, `memory_get` |
|
||||
| `group:web` | `web_search`, `x_search`, `web_fetch` |
|
||||
| `group:ui` | `browser`, `canvas` |
|
||||
| `group:automation` | `heartbeat_respond`, `cron`, `gateway` |
|
||||
| `group:messaging` | `message` |
|
||||
| `group:nodes` | `nodes` |
|
||||
| `group:agents` | `agents_list`, `get_goal`, `create_goal`, `update_goal`, `update_plan`, `skill_workshop` |
|
||||
| `group:media` | `image`, `image_generate`, `music_generate`, `video_generate`, `tts` |
|
||||
| `group:openclaw` | All built-in tools above except `read`/`write`/`edit`/`apply_patch`/`exec`/`process`/`canvas` (excludes plugin tools) |
|
||||
| `group:plugins` | Tools owned by loaded plugins, including configured MCP servers exposed through `bundle-mcp` |
|
||||
|
||||
`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The Control UI shows the title and summary as an actionable chip; accepting it creates a fresh managed-worktree session and sends the full prompt there while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`. Suggestions are process-local and disappear when the Gateway restarts. Both tools are in the `coding` profile and `group:sessions`, so normal `tools.allow` and `tools.deny` policy configures them automatically.
|
||||
|
||||
### MCP and plugin tools inside sandbox tool policy
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ A **Search** field at the top of the sidebar opens the command palette (⌘K). T
|
|||
- Hovering or keyboard-focusing a public GitHub issue or pull request link shows its state, title, author, recent activity, comments, and change statistics. The connected Gateway fetches and caches public metadata without changing the link target, including when the UI uses a remote Gateway. The Gateway uses `GH_TOKEN` or `GITHUB_TOKEN` when available, after confirming the repository is public; otherwise it uses GitHub's anonymous API with a longer cache.
|
||||
- Talk through browser realtime sessions. OpenAI uses direct WebRTC, Google Live uses a constrained one-use browser token over WebSocket, and backend-only realtime voice plugins use the Gateway relay transport. Client-owned provider sessions start with `talk.client.create`; Gateway relay sessions start with `talk.session.create`. The relay keeps provider credentials on the Gateway while the browser streams microphone PCM through `talk.session.appendAudio`, forwards `openclaw_agent_consult` provider tool calls through `talk.client.toolCall` for Gateway policy and the larger configured OpenClaw model, and routes active-run voice steering through `talk.client.steer` or `talk.session.steer`.
|
||||
- Stream tool calls and live tool output cards in Chat (agent events).
|
||||
- Start or dismiss ephemeral model-suggested follow-up tasks; accepted suggestions open a fresh managed-worktree session with the proposed prompt.
|
||||
- Activity tab with browser-local, redaction-first summaries of live tool activity from existing `session.tool` / tool event delivery.
|
||||
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -447,6 +447,28 @@ import {
|
|||
SessionsSendParamsSchema,
|
||||
type SessionsUsageParams,
|
||||
SessionsUsageParamsSchema,
|
||||
type TaskSuggestion,
|
||||
type TaskSuggestionEvent,
|
||||
TaskSuggestionEventSchema,
|
||||
type TaskSuggestionResolution,
|
||||
TaskSuggestionResolutionSchema,
|
||||
TaskSuggestionSchema,
|
||||
type TaskSuggestionsAcceptParams,
|
||||
TaskSuggestionsAcceptParamsSchema,
|
||||
type TaskSuggestionsAcceptResult,
|
||||
TaskSuggestionsAcceptResultSchema,
|
||||
type TaskSuggestionsCreateParams,
|
||||
TaskSuggestionsCreateParamsSchema,
|
||||
type TaskSuggestionsCreateResult,
|
||||
TaskSuggestionsCreateResultSchema,
|
||||
type TaskSuggestionsDismissParams,
|
||||
TaskSuggestionsDismissParamsSchema,
|
||||
type TaskSuggestionsDismissResult,
|
||||
TaskSuggestionsDismissResultSchema,
|
||||
type TaskSuggestionsListParams,
|
||||
TaskSuggestionsListParamsSchema,
|
||||
type TaskSuggestionsListResult,
|
||||
TaskSuggestionsListResultSchema,
|
||||
type TaskSummary,
|
||||
TaskSummarySchema,
|
||||
type TasksCancelParams,
|
||||
|
|
@ -832,6 +854,18 @@ export const validateSessionsCompactionRestoreParams = lazyCompile<SessionsCompa
|
|||
);
|
||||
export const validateSessionsUsageParams =
|
||||
lazyCompile<SessionsUsageParams>(SessionsUsageParamsSchema);
|
||||
export const validateTaskSuggestionsListParams = lazyCompile<TaskSuggestionsListParams>(
|
||||
TaskSuggestionsListParamsSchema,
|
||||
);
|
||||
export const validateTaskSuggestionsCreateParams = lazyCompile<TaskSuggestionsCreateParams>(
|
||||
TaskSuggestionsCreateParamsSchema,
|
||||
);
|
||||
export const validateTaskSuggestionsAcceptParams = lazyCompile<TaskSuggestionsAcceptParams>(
|
||||
TaskSuggestionsAcceptParamsSchema,
|
||||
);
|
||||
export const validateTaskSuggestionsDismissParams = lazyCompile<TaskSuggestionsDismissParams>(
|
||||
TaskSuggestionsDismissParamsSchema,
|
||||
);
|
||||
export const validateTasksListParams = lazyCompile<TasksListParams>(TasksListParamsSchema);
|
||||
export const validateTasksGetParams = lazyCompile<TasksGetParams>(TasksGetParamsSchema);
|
||||
export const validateTasksCancelParams = lazyCompile<TasksCancelParams>(TasksCancelParamsSchema);
|
||||
|
|
@ -1242,6 +1276,17 @@ export {
|
|||
AuditEventSchema,
|
||||
AuditListParamsSchema,
|
||||
AuditListResultSchema,
|
||||
TaskSuggestionSchema,
|
||||
TaskSuggestionEventSchema,
|
||||
TaskSuggestionResolutionSchema,
|
||||
TaskSuggestionsAcceptParamsSchema,
|
||||
TaskSuggestionsAcceptResultSchema,
|
||||
TaskSuggestionsCreateParamsSchema,
|
||||
TaskSuggestionsCreateResultSchema,
|
||||
TaskSuggestionsDismissParamsSchema,
|
||||
TaskSuggestionsDismissResultSchema,
|
||||
TaskSuggestionsListParamsSchema,
|
||||
TaskSuggestionsListResultSchema,
|
||||
TaskSummarySchema,
|
||||
TasksListParamsSchema,
|
||||
TasksListResultSchema,
|
||||
|
|
@ -1631,6 +1676,17 @@ export type {
|
|||
AuditEvent,
|
||||
AuditListParams,
|
||||
AuditListResult,
|
||||
TaskSuggestion,
|
||||
TaskSuggestionEvent,
|
||||
TaskSuggestionResolution,
|
||||
TaskSuggestionsAcceptParams,
|
||||
TaskSuggestionsAcceptResult,
|
||||
TaskSuggestionsCreateParams,
|
||||
TaskSuggestionsCreateResult,
|
||||
TaskSuggestionsDismissParams,
|
||||
TaskSuggestionsDismissResult,
|
||||
TaskSuggestionsListParams,
|
||||
TaskSuggestionsListResult,
|
||||
TaskSummary,
|
||||
TasksListParams,
|
||||
TasksListResult,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export * from "./schema/secrets.js";
|
|||
export * from "./schema/sessions.js";
|
||||
export * from "./schema/snapshot.js";
|
||||
export * from "./schema/system-info.js";
|
||||
export * from "./schema/task-suggestions.js";
|
||||
export * from "./schema/tasks.js";
|
||||
export * from "./schema/terminal.js";
|
||||
export * from "./schema/types.js";
|
||||
|
|
|
|||
|
|
@ -321,6 +321,19 @@ import {
|
|||
} from "./sessions.js";
|
||||
import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||
import { SystemInfoParamsSchema, SystemInfoResultSchema } from "./system-info.js";
|
||||
import {
|
||||
TaskSuggestionEventSchema,
|
||||
TaskSuggestionResolutionSchema,
|
||||
TaskSuggestionSchema,
|
||||
TaskSuggestionsAcceptParamsSchema,
|
||||
TaskSuggestionsAcceptResultSchema,
|
||||
TaskSuggestionsCreateParamsSchema,
|
||||
TaskSuggestionsCreateResultSchema,
|
||||
TaskSuggestionsDismissParamsSchema,
|
||||
TaskSuggestionsDismissResultSchema,
|
||||
TaskSuggestionsListParamsSchema,
|
||||
TaskSuggestionsListResultSchema,
|
||||
} from "./task-suggestions.js";
|
||||
import {
|
||||
TasksCancelParamsSchema,
|
||||
TasksCancelResultSchema,
|
||||
|
|
@ -486,6 +499,17 @@ export const ProtocolSchemas = {
|
|||
AuditEvent: AuditEventSchema,
|
||||
AuditListParams: AuditListParamsSchema,
|
||||
AuditListResult: AuditListResultSchema,
|
||||
TaskSuggestion: TaskSuggestionSchema,
|
||||
TaskSuggestionEvent: TaskSuggestionEventSchema,
|
||||
TaskSuggestionResolution: TaskSuggestionResolutionSchema,
|
||||
TaskSuggestionsAcceptParams: TaskSuggestionsAcceptParamsSchema,
|
||||
TaskSuggestionsAcceptResult: TaskSuggestionsAcceptResultSchema,
|
||||
TaskSuggestionsCreateParams: TaskSuggestionsCreateParamsSchema,
|
||||
TaskSuggestionsCreateResult: TaskSuggestionsCreateResultSchema,
|
||||
TaskSuggestionsDismissParams: TaskSuggestionsDismissParamsSchema,
|
||||
TaskSuggestionsDismissResult: TaskSuggestionsDismissResultSchema,
|
||||
TaskSuggestionsListParams: TaskSuggestionsListParamsSchema,
|
||||
TaskSuggestionsListResult: TaskSuggestionsListResultSchema,
|
||||
TaskSummary: TaskSummarySchema,
|
||||
TasksListParams: TasksListParamsSchema,
|
||||
TasksListResult: TasksListResultSchema,
|
||||
|
|
|
|||
|
|
@ -256,6 +256,13 @@ export const SessionsCreateParamsSchema = Type.Object(
|
|||
task: Type.Optional(Type.String()),
|
||||
message: Type.Optional(Type.String()),
|
||||
worktree: Type.Optional(Type.Boolean()),
|
||||
cwd: Type.Optional(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
"Absolute source directory for a managed worktree. Requires worktree=true and operator.admin.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
|
|
|||
104
packages/gateway-protocol/src/schema/task-suggestions.ts
Normal file
104
packages/gateway-protocol/src/schema/task-suggestions.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// Gateway Protocol schema module defines ephemeral follow-up task suggestions.
|
||||
import { Type } from "typebox";
|
||||
|
||||
const TaskIdSchema = Type.String({ minLength: 1, maxLength: 128 });
|
||||
const TaskTitleSchema = Type.String({ minLength: 1, maxLength: 60 });
|
||||
const TaskPromptSchema = Type.String({ minLength: 1, maxLength: 32_768 });
|
||||
const TaskTldrSchema = Type.String({ minLength: 1, maxLength: 1_024 });
|
||||
const TaskCwdSchema = Type.String({ minLength: 1, maxLength: 4_096 });
|
||||
const TaskSessionKeySchema = Type.String({ minLength: 1, maxLength: 512 });
|
||||
const TaskAgentIdSchema = Type.String({ minLength: 1, maxLength: 128 });
|
||||
|
||||
/** One model-proposed follow-up task waiting for operator action. */
|
||||
export const TaskSuggestionSchema = Type.Object(
|
||||
{
|
||||
id: TaskIdSchema,
|
||||
title: TaskTitleSchema,
|
||||
prompt: TaskPromptSchema,
|
||||
tldr: TaskTldrSchema,
|
||||
cwd: TaskCwdSchema,
|
||||
sessionKey: TaskSessionKeySchema,
|
||||
agentId: Type.Optional(TaskAgentIdSchema),
|
||||
createdAt: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Lists pending suggestions, optionally narrowed to one source session. */
|
||||
export const TaskSuggestionsListParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: Type.Optional(TaskSessionKeySchema),
|
||||
agentId: Type.Optional(TaskAgentIdSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TaskSuggestionsListResultSchema = Type.Object(
|
||||
{ suggestions: Type.Array(TaskSuggestionSchema) },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Creates a pending suggestion without starting any work. */
|
||||
export const TaskSuggestionsCreateParamsSchema = Type.Object(
|
||||
{
|
||||
title: TaskTitleSchema,
|
||||
prompt: TaskPromptSchema,
|
||||
tldr: TaskTldrSchema,
|
||||
cwd: TaskCwdSchema,
|
||||
sessionKey: TaskSessionKeySchema,
|
||||
agentId: Type.Optional(TaskAgentIdSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TaskSuggestionsCreateResultSchema = Type.Object(
|
||||
{ taskId: TaskIdSchema, suggestion: TaskSuggestionSchema },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TaskSuggestionResolutionSchema = Type.Union([
|
||||
Type.Literal("dismissed"),
|
||||
Type.Literal("accepted"),
|
||||
Type.Literal("expired"),
|
||||
]);
|
||||
|
||||
/** Atomically claims a pending suggestion and starts its server-owned worktree session. */
|
||||
export const TaskSuggestionsAcceptParamsSchema = Type.Object(
|
||||
{ taskId: TaskIdSchema },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TaskSuggestionsAcceptResultSchema = Type.Object(
|
||||
{ taskId: TaskIdSchema, key: TaskSessionKeySchema },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Removes a pending suggestion without starting work. */
|
||||
export const TaskSuggestionsDismissParamsSchema = Type.Object(
|
||||
{
|
||||
taskId: TaskIdSchema,
|
||||
reason: Type.Optional(Type.String({ maxLength: 1_024 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TaskSuggestionsDismissResultSchema = Type.Object(
|
||||
{ taskId: TaskIdSchema, dismissed: Type.Boolean() },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** Live update emitted when a pending suggestion is created or resolved. */
|
||||
export const TaskSuggestionEventSchema = Type.Union([
|
||||
Type.Object(
|
||||
{ action: Type.Literal("created"), suggestion: TaskSuggestionSchema },
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
action: Type.Literal("resolved"),
|
||||
taskId: TaskIdSchema,
|
||||
resolution: TaskSuggestionResolutionSchema,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
]);
|
||||
|
|
@ -33,6 +33,17 @@ export type EnvironmentsStatusParams = SchemaType<"EnvironmentsStatusParams">;
|
|||
export type EnvironmentsStatusResult = SchemaType<"EnvironmentsStatusResult">;
|
||||
export type SystemInfoParams = SchemaType<"SystemInfoParams">;
|
||||
export type SystemInfoResult = SchemaType<"SystemInfoResult">;
|
||||
export type TaskSuggestion = SchemaType<"TaskSuggestion">;
|
||||
export type TaskSuggestionEvent = SchemaType<"TaskSuggestionEvent">;
|
||||
export type TaskSuggestionResolution = SchemaType<"TaskSuggestionResolution">;
|
||||
export type TaskSuggestionsAcceptParams = SchemaType<"TaskSuggestionsAcceptParams">;
|
||||
export type TaskSuggestionsAcceptResult = SchemaType<"TaskSuggestionsAcceptResult">;
|
||||
export type TaskSuggestionsCreateParams = SchemaType<"TaskSuggestionsCreateParams">;
|
||||
export type TaskSuggestionsCreateResult = SchemaType<"TaskSuggestionsCreateResult">;
|
||||
export type TaskSuggestionsDismissParams = SchemaType<"TaskSuggestionsDismissParams">;
|
||||
export type TaskSuggestionsDismissResult = SchemaType<"TaskSuggestionsDismissResult">;
|
||||
export type TaskSuggestionsListParams = SchemaType<"TaskSuggestionsListParams">;
|
||||
export type TaskSuggestionsListResult = SchemaType<"TaskSuggestionsListResult">;
|
||||
export type WorktreeRecord = SchemaType<"WorktreeRecord">;
|
||||
export type WorktreesListParams = SchemaType<"WorktreesListParams">;
|
||||
export type WorktreesListResult = SchemaType<"WorktreesListResult">;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"ios": {
|
||||
"session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.",
|
||||
"session.tool": "Session tool stream is not rendered by the iOS chat surface yet.",
|
||||
"task.suggestion": "Task suggestion cards are a Control UI-only surface; iOS does not render them.",
|
||||
"sessions.changed": "iOS refreshes session lists via explicit sessions.* requests instead of this push event.",
|
||||
"presence": "Presence roster is a control-UI (web/desktop) surface; iOS does not render it.",
|
||||
"shutdown": "iOS relies on socket close plus reconnect/backoff instead of the shutdown notice.",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"android": {
|
||||
"session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.",
|
||||
"session.tool": "Session tool stream is not rendered by the Android chat surface yet.",
|
||||
"task.suggestion": "Task suggestion cards are a Control UI-only surface; Android does not render them.",
|
||||
"presence": "Presence roster is a control-UI (web/desktop) surface; Android does not render it.",
|
||||
"talk.mode": "Android toggles talk mode locally; gateway talk.mode sync is not consumed.",
|
||||
"shutdown": "Android relies on socket close plus reconnect/backoff instead of the shutdown notice.",
|
||||
|
|
|
|||
|
|
@ -599,6 +599,42 @@ describe("createOpenClawCodingTools", () => {
|
|||
expect(latestCreateOpenClawToolsOptions().sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
});
|
||||
|
||||
it("uses the canonical spawn workspace for follow-up task suggestions", () => {
|
||||
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
|
||||
createOpenClawToolsMock.mockClear();
|
||||
const sandboxDir = "/sandbox/workspace";
|
||||
|
||||
createOpenClawCodingTools({
|
||||
sandbox: createAgentToolsSandboxContext({
|
||||
workspaceDir: sandboxDir,
|
||||
workspaceAccess: "ro",
|
||||
fsBridge: createHostSandboxFsBridge(sandboxDir),
|
||||
}),
|
||||
workspaceDir: "/agent/workspace",
|
||||
cwd: sandboxDir,
|
||||
spawnWorkspaceDir: "/host/project",
|
||||
});
|
||||
|
||||
expect(latestCreateOpenClawToolsOptions()).toMatchObject({
|
||||
workspaceDir: "/agent/workspace",
|
||||
spawnWorkspaceDir: "/host/project",
|
||||
cwd: "/host/project",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an unsandboxed task repo as the follow-up suggestion cwd", () => {
|
||||
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
|
||||
createOpenClawToolsMock.mockClear();
|
||||
|
||||
createOpenClawCodingTools({
|
||||
workspaceDir: "/agent/workspace",
|
||||
cwd: "/task/repo",
|
||||
spawnWorkspaceDir: "/agent/workspace",
|
||||
});
|
||||
|
||||
expect(latestCreateOpenClawToolsOptions().cwd).toBe("/task/repo");
|
||||
});
|
||||
|
||||
it("skips unrelated tool families when construction is planned from a narrow allowlist", () => {
|
||||
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
|
||||
createOpenClawToolsMock.mockClear();
|
||||
|
|
|
|||
|
|
@ -1008,6 +1008,11 @@ export function createOpenClawCodingTools(options?: {
|
|||
fsPolicy,
|
||||
workspaceDir: workspaceRoot,
|
||||
spawnWorkspaceDir: capabilityProfile.workspace.spawnWorkspaceRoot,
|
||||
// Sandboxes execute against copied roots, but accepted suggestions create host
|
||||
// worktrees. Unsandboxed task-repo sessions must stay on their runtime cwd.
|
||||
cwd: sandbox
|
||||
? (capabilityProfile.workspace.spawnWorkspaceRoot ?? runtimeRoot)
|
||||
: runtimeRoot,
|
||||
sandboxed: Boolean(sandbox),
|
||||
config: options?.config,
|
||||
pluginToolAllowlist,
|
||||
|
|
|
|||
|
|
@ -349,6 +349,23 @@ describe("resolveEmbeddedAttemptToolConstructionPlan", () => {
|
|||
},
|
||||
},
|
||||
);
|
||||
for (const toolName of ["spawn_task", "dismiss_task"]) {
|
||||
expectConstructionPlan(
|
||||
resolveEmbeddedAttemptToolConstructionPlan({ toolsAllow: [toolName] }),
|
||||
{
|
||||
constructTools: true,
|
||||
includeCoreTools: true,
|
||||
runtimeToolAllowlist: [toolName],
|
||||
coding: {
|
||||
includeBaseCodingTools: false,
|
||||
includeShellTools: false,
|
||||
includeChannelTools: false,
|
||||
includeOpenClawTools: true,
|
||||
includePluginTools: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps plugin-owned catalog tools on the plugin construction path", () => {
|
||||
|
|
|
|||
|
|
@ -41,11 +41,13 @@ const OPENCLAW_TOOL_FACTORY_NAMES = new Set([
|
|||
"sessions_spawn",
|
||||
"sessions_yield",
|
||||
"skill_workshop",
|
||||
"spawn_task",
|
||||
"create_goal",
|
||||
"subagents",
|
||||
"tts",
|
||||
"update_goal",
|
||||
"update_plan",
|
||||
"dismiss_task",
|
||||
"video_generate",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
|
|
|
|||
|
|
@ -122,4 +122,29 @@ describe("runEmbeddedAttempt cwd/workspace split", () => {
|
|||
).rejects.toThrow("cwd override is not supported");
|
||||
expect(hoisted.createOpenClawCodingToolsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs a managed worktree when sandbox workspace and cwd match", async () => {
|
||||
const worktree = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sandbox-worktree-"));
|
||||
tempPaths.push(worktree);
|
||||
hoisted.resolveSandboxContextMock.mockResolvedValueOnce({
|
||||
enabled: true,
|
||||
workspaceAccess: "rw",
|
||||
workspaceDir: worktree,
|
||||
});
|
||||
|
||||
await createContextEngineAttemptRunner({
|
||||
contextEngine: createContextEngineBootstrapAndAssemble(),
|
||||
sessionKey: "agent:main:dashboard:worktree",
|
||||
tempPaths,
|
||||
attemptOverrides: {
|
||||
workspaceDir: worktree,
|
||||
cwd: worktree,
|
||||
disableTools: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(hoisted.createOpenClawCodingToolsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: worktree, workspaceDir: worktree }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
|||
import { createSessionsYieldTool } from "./tools/sessions-yield-tool.js";
|
||||
import { createSkillWorkshopTool } from "./tools/skill-workshop-tool.js";
|
||||
import { createSubagentsTool } from "./tools/subagents-tool.js";
|
||||
import { createTaskSuggestionTools } from "./tools/task-suggestion-tools.js";
|
||||
import { createTranscriptsTool } from "./tools/transcripts-tool.js";
|
||||
import { createTtsTool } from "./tools/tts-tool.js";
|
||||
import { createUpdatePlanTool } from "./tools/update-plan-tool.js";
|
||||
|
|
@ -189,6 +190,8 @@ export function createOpenClawTools(
|
|||
* subagents inherit the real workspace path instead of the sandbox copy.
|
||||
*/
|
||||
spawnWorkspaceDir?: string;
|
||||
/** Current runtime directory used as the default project for follow-up suggestions. */
|
||||
cwd?: string;
|
||||
/** Callback invoked when sessions_yield tool is called. */
|
||||
onYield?: (message: string) => Promise<void> | void;
|
||||
/** Allow plugin tools for this tool set to late-bind the gateway subagent. */
|
||||
|
|
@ -217,6 +220,9 @@ export function createOpenClawTools(
|
|||
const spawnWorkspaceDir = resolveWorkspaceRoot(
|
||||
options?.spawnWorkspaceDir ?? options?.workspaceDir ?? inferredWorkspaceDir,
|
||||
);
|
||||
const runtimeCwd = resolveWorkspaceRoot(
|
||||
options?.cwd ?? options?.workspaceDir ?? inferredWorkspaceDir,
|
||||
);
|
||||
options?.recordToolPrepStage?.("openclaw-tools:session-workspace");
|
||||
const deliveryContext = normalizeDeliveryContext({
|
||||
channel: options?.agentChannel,
|
||||
|
|
@ -253,6 +259,9 @@ export function createOpenClawTools(
|
|||
const skillWorkshopMessageId = normalizeOptionalString(
|
||||
options?.currentMessageId === undefined ? undefined : String(options.currentMessageId),
|
||||
);
|
||||
const taskSuggestionSessionKey = normalizeOptionalString(
|
||||
options?.runSessionKey ?? options?.agentSessionKey,
|
||||
);
|
||||
const imageToolAgentDir = options?.agentDir;
|
||||
const imageTool = resolveImageToolFactoryAvailable({
|
||||
config: availabilityConfig ?? resolvedConfig,
|
||||
|
|
@ -445,6 +454,13 @@ export function createOpenClawTools(
|
|||
: {}),
|
||||
}),
|
||||
]),
|
||||
...(!embedded && taskSuggestionSessionKey
|
||||
? createTaskSuggestionTools({
|
||||
sessionKey: taskSuggestionSessionKey,
|
||||
agentId: sessionAgentId,
|
||||
cwd: runtimeCwd,
|
||||
})
|
||||
: []),
|
||||
...(messageTool && includeMessageTool ? [messageTool] : []),
|
||||
...collectPresentOpenClawTools([heartbeatTool]),
|
||||
createTtsTool({
|
||||
|
|
|
|||
|
|
@ -126,6 +126,22 @@ describe("openclaw-tools update_plan gating", () => {
|
|||
expect(enabledTools).toContain("transcripts");
|
||||
});
|
||||
|
||||
it("registers task suggestions for gateway-backed sessions", () => {
|
||||
const withoutSession = createFastToolNames({
|
||||
config: {} as OpenClawConfig,
|
||||
cwd: "/repo",
|
||||
});
|
||||
const withSession = createFastToolNames({
|
||||
config: {} as OpenClawConfig,
|
||||
agentSessionKey: "agent:main:main",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(withoutSession).not.toContain("spawn_task");
|
||||
expect(withoutSession).not.toContain("dismiss_task");
|
||||
expect(withSession).toEqual(expect.arrayContaining(["spawn_task", "dismiss_task"]));
|
||||
});
|
||||
|
||||
it("keeps explicitly allowed message tool in embedded completions", () => {
|
||||
setEmbeddedMode(true);
|
||||
const fromRuntimeAllowlist = createOpenClawTools({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
mapToolContextToSpawnedRunMetadata,
|
||||
normalizeSpawnedRunMetadata,
|
||||
resolveIngressWorkspaceOverrideForSpawnedRun,
|
||||
resolveIngressWorkspaceOverrideForSessionRun,
|
||||
resolveSpawnedWorkspaceInheritance,
|
||||
} from "./spawned-context.js";
|
||||
|
||||
|
|
@ -93,19 +93,22 @@ describe("resolveSpawnedWorkspaceInheritance", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("resolveIngressWorkspaceOverrideForSpawnedRun", () => {
|
||||
it("forwards workspace only for spawned runs", () => {
|
||||
describe("resolveIngressWorkspaceOverrideForSessionRun", () => {
|
||||
it("uses inherited workspaces for spawned runs and managed cwd for dashboard worktrees", () => {
|
||||
expect(
|
||||
resolveIngressWorkspaceOverrideForSpawnedRun({
|
||||
resolveIngressWorkspaceOverrideForSessionRun({
|
||||
spawnedBy: "agent:main:subagent:parent",
|
||||
workspaceDir: "/tmp/ws",
|
||||
cwd: "/tmp/task",
|
||||
}),
|
||||
).toBe("/tmp/ws");
|
||||
expect(
|
||||
resolveIngressWorkspaceOverrideForSpawnedRun({
|
||||
resolveIngressWorkspaceOverrideForSessionRun({
|
||||
spawnedBy: "",
|
||||
workspaceDir: "/tmp/ws",
|
||||
cwd: "/tmp/worktree",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
).toBe("/tmp/worktree");
|
||||
expect(resolveIngressWorkspaceOverrideForSessionRun()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,10 +79,19 @@ export function resolveSpawnedWorkspaceInheritance(params: {
|
|||
return agentId ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(agentId)) : undefined;
|
||||
}
|
||||
|
||||
/** Return a spawned run's ingress workspace override only for child runs. */
|
||||
export function resolveIngressWorkspaceOverrideForSpawnedRun(
|
||||
metadata?: Pick<SpawnedRunMetadata, "spawnedBy" | "workspaceDir"> | null,
|
||||
/** Resolve the persisted workspace used when a session re-enters an agent runtime. */
|
||||
export function resolveIngressWorkspaceOverrideForSessionRun(
|
||||
metadata?:
|
||||
| (Pick<SpawnedRunMetadata, "spawnedBy" | "workspaceDir"> & {
|
||||
cwd?: string | null;
|
||||
})
|
||||
| null,
|
||||
): string | undefined {
|
||||
const normalized = normalizeSpawnedRunMetadata(metadata);
|
||||
return normalized.spawnedBy ? normalized.workspaceDir : undefined;
|
||||
if (normalized.spawnedBy) {
|
||||
return normalized.workspaceDir;
|
||||
}
|
||||
// Dashboard worktree sessions are not subagents, so their managed cwd is
|
||||
// also the workspace that sandbox setup must mount on every later turn.
|
||||
return normalizeOptionalString(metadata?.cwd);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ describe("tool-catalog", () => {
|
|||
"sessions_yield",
|
||||
"subagents",
|
||||
"session_status",
|
||||
"spawn_task",
|
||||
"dismiss_task",
|
||||
"cron",
|
||||
"get_goal",
|
||||
"create_goal",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
SESSIONS_SEND_TOOL_DISPLAY_SUMMARY,
|
||||
SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY,
|
||||
SESSION_STATUS_TOOL_DISPLAY_SUMMARY,
|
||||
SPAWN_TASK_TOOL_DISPLAY_SUMMARY,
|
||||
DISMISS_TASK_TOOL_DISPLAY_SUMMARY,
|
||||
UPDATE_PLAN_TOOL_DISPLAY_SUMMARY,
|
||||
} from "./tool-description-presets.js";
|
||||
|
||||
|
|
@ -204,6 +206,22 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
|
|||
profiles: ["minimal", "coding", "messaging"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "spawn_task",
|
||||
label: "spawn_task",
|
||||
description: SPAWN_TASK_TOOL_DISPLAY_SUMMARY,
|
||||
sectionId: "sessions",
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "dismiss_task",
|
||||
label: "dismiss_task",
|
||||
description: DISMISS_TASK_TOOL_DISPLAY_SUMMARY,
|
||||
sectionId: "sessions",
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "browser",
|
||||
label: "browser",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export const SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY = "Spawn subagent or ACP sessio
|
|||
export const SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY = "Spawn subagent session.";
|
||||
export const SESSION_STATUS_TOOL_DISPLAY_SUMMARY = "Show session status/model/usage.";
|
||||
export const UPDATE_PLAN_TOOL_DISPLAY_SUMMARY = "Track short work plan.";
|
||||
export const SPAWN_TASK_TOOL_DISPLAY_SUMMARY = "Suggest follow-up work for operator approval.";
|
||||
export const DISMISS_TASK_TOOL_DISPLAY_SUMMARY = "Withdraw a pending task suggestion.";
|
||||
|
||||
/** Describes the sessions_list tool for model-facing instructions. */
|
||||
export function describeSessionsListTool(): string {
|
||||
|
|
|
|||
|
|
@ -285,6 +285,16 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
|
|||
title: "Update Plan",
|
||||
detailKeys: ["explanation", "plan.0.step"],
|
||||
},
|
||||
spawn_task: {
|
||||
emoji: "✨",
|
||||
title: "Suggest Task",
|
||||
detailKeys: ["title", "tldr", "cwd"],
|
||||
},
|
||||
dismiss_task: {
|
||||
emoji: "🗑️",
|
||||
title: "Dismiss Task",
|
||||
detailKeys: ["task_id", "reason"],
|
||||
},
|
||||
skill_workshop: {
|
||||
emoji: "🧰",
|
||||
title: "Skill Workshop",
|
||||
|
|
|
|||
74
src/agents/tools/task-suggestion-tools.test.ts
Normal file
74
src/agents/tools/task-suggestion-tools.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTaskSuggestionTools } from "./task-suggestion-tools.js";
|
||||
|
||||
function createTools(gatewayCall = vi.fn()) {
|
||||
return {
|
||||
gatewayCall,
|
||||
tools: createTaskSuggestionTools({
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
cwd: "/repo",
|
||||
callGateway: gatewayCall as never,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("task suggestion tools", () => {
|
||||
it("creates a suggestion without starting work", async () => {
|
||||
const { gatewayCall, tools } = createTools(
|
||||
vi.fn(async () => ({ taskId: "task_123", suggestion: {} })),
|
||||
);
|
||||
const spawnTask = tools.find((tool) => tool.name === "spawn_task");
|
||||
|
||||
const result = await spawnTask?.execute("call-1", {
|
||||
title: "Remove stale adapter",
|
||||
prompt: "Delete the unused adapter in src/example.ts and update its tests.",
|
||||
tldr: "The adapter is no longer reachable. Removing it reduces maintenance cost.",
|
||||
});
|
||||
|
||||
expect(gatewayCall).toHaveBeenCalledWith(
|
||||
"taskSuggestions.create",
|
||||
{},
|
||||
{
|
||||
title: "Remove stale adapter",
|
||||
prompt: "Delete the unused adapter in src/example.ts and update its tests.",
|
||||
tldr: "The adapter is no longer reachable. Removing it reduces maintenance cost.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
},
|
||||
);
|
||||
expect(result?.content).toEqual([
|
||||
{ type: "text", text: JSON.stringify({ task_id: "task_123" }, null, 2) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("withdraws a pending suggestion", async () => {
|
||||
const { gatewayCall, tools } = createTools(
|
||||
vi.fn(async () => ({ taskId: "task_123", dismissed: true })),
|
||||
);
|
||||
const dismissTask = tools.find((tool) => tool.name === "dismiss_task");
|
||||
|
||||
await dismissTask?.execute("call-2", { task_id: "task_123", reason: "Already fixed" });
|
||||
|
||||
expect(gatewayCall).toHaveBeenCalledWith(
|
||||
"taskSuggestions.dismiss",
|
||||
{},
|
||||
{ taskId: "task_123", reason: "Already fixed" },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects relative project directories", async () => {
|
||||
const { tools } = createTools();
|
||||
const spawnTask = tools.find((tool) => tool.name === "spawn_task");
|
||||
|
||||
await expect(
|
||||
spawnTask?.execute("call-3", {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is confirmed and untested.",
|
||||
cwd: "relative/repo",
|
||||
}),
|
||||
).rejects.toThrow("cwd must be an absolute path");
|
||||
});
|
||||
});
|
||||
127
src/agents/tools/task-suggestion-tools.ts
Normal file
127
src/agents/tools/task-suggestion-tools.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/** Model tools for proposing and withdrawing operator-approved follow-up work. */
|
||||
import path from "node:path";
|
||||
import { Type } from "typebox";
|
||||
import type {
|
||||
TaskSuggestionsCreateResult,
|
||||
TaskSuggestionsDismissResult,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
DISMISS_TASK_TOOL_DISPLAY_SUMMARY,
|
||||
SPAWN_TASK_TOOL_DISPLAY_SUMMARY,
|
||||
} from "../tool-description-presets.js";
|
||||
import { type AnyAgentTool, ToolInputError, jsonResult, readStringParam } from "./common.js";
|
||||
import { callGatewayTool } from "./gateway.js";
|
||||
|
||||
const SpawnTaskToolSchema = Type.Object(
|
||||
{
|
||||
title: Type.String({
|
||||
minLength: 1,
|
||||
maxLength: 60,
|
||||
description: "Imperative task title under 60 characters.",
|
||||
}),
|
||||
prompt: Type.String({
|
||||
minLength: 1,
|
||||
maxLength: 32_768,
|
||||
description: "Self-contained task prompt with relevant file paths and context.",
|
||||
}),
|
||||
tldr: Type.String({
|
||||
minLength: 1,
|
||||
maxLength: 1_024,
|
||||
description: "One or two plain-language sentences explaining the value; no code or paths.",
|
||||
}),
|
||||
cwd: Type.Optional(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
maxLength: 4_096,
|
||||
description: "Absolute project directory; defaults to the current project.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const DismissTaskToolSchema = Type.Object(
|
||||
{
|
||||
task_id: Type.String({
|
||||
minLength: 1,
|
||||
maxLength: 128,
|
||||
description: "ID returned by spawn_task.",
|
||||
}),
|
||||
reason: Type.Optional(
|
||||
Type.String({ maxLength: 1_024, description: "Short reason the suggestion is stale." }),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
type GatewayCaller = typeof callGatewayTool;
|
||||
|
||||
export function createTaskSuggestionTools(params: {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
cwd: string;
|
||||
callGateway?: GatewayCaller;
|
||||
}): AnyAgentTool[] {
|
||||
const gatewayCall = params.callGateway ?? callGatewayTool;
|
||||
return [
|
||||
{
|
||||
label: "Suggest Task",
|
||||
name: "spawn_task",
|
||||
displaySummary: SPAWN_TASK_TOOL_DISPLAY_SUMMARY,
|
||||
description: [
|
||||
"Suggest valuable follow-up work discovered during the current task.",
|
||||
"Use only for confirmed, out-of-scope work such as dead code, stale docs, missing coverage, a verified TODO, or a security issue.",
|
||||
"This creates an operator-facing suggestion only; it does not start work.",
|
||||
].join(" "),
|
||||
parameters: SpawnTaskToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const input = args as Record<string, unknown>;
|
||||
const title = readStringParam(input, "title", { required: true });
|
||||
const prompt = readStringParam(input, "prompt", { required: true });
|
||||
const tldr = readStringParam(input, "tldr", { required: true });
|
||||
const cwd = readStringParam(input, "cwd") ?? params.cwd;
|
||||
if (title.length > 60) {
|
||||
throw new ToolInputError("title must be at most 60 characters");
|
||||
}
|
||||
if (!path.isAbsolute(cwd)) {
|
||||
throw new ToolInputError("cwd must be an absolute path");
|
||||
}
|
||||
const result = await gatewayCall<TaskSuggestionsCreateResult>(
|
||||
"taskSuggestions.create",
|
||||
{},
|
||||
{
|
||||
title,
|
||||
prompt,
|
||||
tldr,
|
||||
cwd,
|
||||
sessionKey: params.sessionKey,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
},
|
||||
);
|
||||
return jsonResult({ task_id: result.taskId });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Dismiss Task",
|
||||
name: "dismiss_task",
|
||||
displaySummary: DISMISS_TASK_TOOL_DISPLAY_SUMMARY,
|
||||
description:
|
||||
"Withdraw a still-pending spawn_task suggestion that became stale or irrelevant. Suggestions already accepted by the operator cannot be withdrawn.",
|
||||
parameters: DismissTaskToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const input = args as Record<string, unknown>;
|
||||
const taskId = readStringParam(input, "task_id", { required: true });
|
||||
const reason = readStringParam(input, "reason");
|
||||
const result = await gatewayCall<TaskSuggestionsDismissResult>(
|
||||
"taskSuggestions.dismiss",
|
||||
{},
|
||||
{
|
||||
taskId,
|
||||
...(reason ? { reason } : {}),
|
||||
},
|
||||
);
|
||||
return jsonResult({ task_id: taskId, dismissed: result.dismissed });
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -459,6 +459,14 @@ export class ManagedWorktreeService {
|
|||
return findLiveRegistryWorktreeByOwner(this.env, ownerKind, ownerId);
|
||||
}
|
||||
|
||||
/** Resolves the canonical registry root and the caller's own checkout root. */
|
||||
async resolveRepositoryPaths(
|
||||
repoRoot: string,
|
||||
): Promise<{ canonicalRoot: string; sourceRoot: string }> {
|
||||
const resolved = await resolveRepository(repoRoot);
|
||||
return { canonicalRoot: resolved.repoRoot, sourceRoot: resolved.sourceRoot };
|
||||
}
|
||||
|
||||
async acquire(id: string): Promise<ManagedWorktreeRecord> {
|
||||
const record = this.requireLiveRecord(id);
|
||||
const result = await runGit(record.repoRoot, [
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { resolveFastModeState } from "../../agents/fast-mode.js";
|
|||
import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js";
|
||||
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
|
||||
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js";
|
||||
import { resolveIngressWorkspaceOverrideForSpawnedRun } from "../../agents/spawned-context.js";
|
||||
import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js";
|
||||
import type { SilentReplyPromptMode } from "../../agents/system-prompt.types.js";
|
||||
import { normalizeChatType } from "../../channels/chat-type.js";
|
||||
import { updateAmbientTranscriptWatermark } from "../../config/sessions/ambient-transcript-watermark.js";
|
||||
|
|
@ -544,7 +544,7 @@ export async function runPreparedReply(
|
|||
sessionKey,
|
||||
sessionId,
|
||||
storePath,
|
||||
workspaceDir,
|
||||
workspaceDir: configuredWorkspaceDir,
|
||||
sessionEntryHandle,
|
||||
sessionStore,
|
||||
} = params;
|
||||
|
|
@ -741,17 +741,19 @@ export async function runPreparedReply(
|
|||
rawBodyTrimmed.length > 0)));
|
||||
const startupAction =
|
||||
softResetTriggered || /^\/reset(?:\s|$)/i.test(normalizedCommandBody) ? "reset" : "new";
|
||||
const spawnedWorkspaceOverride = resolveIngressWorkspaceOverrideForSpawnedRun({
|
||||
const sessionWorkspaceOverride = resolveIngressWorkspaceOverrideForSessionRun({
|
||||
spawnedBy: sessionEntry?.spawnedBy,
|
||||
workspaceDir: sessionEntry?.spawnedWorkspaceDir,
|
||||
cwd: sessionEntry?.spawnedCwd,
|
||||
});
|
||||
const workspaceDir = sessionWorkspaceOverride ?? configuredWorkspaceDir;
|
||||
const bareResetPromptState =
|
||||
isBareSessionReset && workspaceDir
|
||||
? await resolveBareSessionResetPromptState({
|
||||
cfg,
|
||||
workspaceDir,
|
||||
isPrimaryRun: !isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey),
|
||||
isCanonicalWorkspace: !spawnedWorkspaceOverride,
|
||||
isCanonicalWorkspace: !sessionWorkspaceOverride,
|
||||
hasBootstrapFileAccess: () =>
|
||||
resolveBareResetBootstrapFileAccess({
|
||||
cfg,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { buildSandboxFsMounts } from "../agents/sandbox/fs-paths.js";
|
|||
import { resolveSandboxRuntimeStatus } from "../agents/sandbox/runtime-status.js";
|
||||
import { resolveSandboxWorkspaceLayoutPaths } from "../agents/sandbox/shared.js";
|
||||
import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js";
|
||||
import { resolveIngressWorkspaceOverrideForSpawnedRun } from "../agents/spawned-context.js";
|
||||
import { resolveIngressWorkspaceOverrideForSessionRun } from "../agents/spawned-context.js";
|
||||
import { normalizeAnyChannelId } from "../channels/registry.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import {
|
||||
|
|
@ -198,9 +198,10 @@ export async function sandboxExplainCommand(
|
|||
// later turns keep running in the same location. Explain must mirror those
|
||||
// overrides or its effective paths point at a different runtime.
|
||||
const configuredWorkspaceDir = resolveAgentWorkspaceDir(cfg, resolvedAgentId);
|
||||
const sessionWorkspaceDir = resolveIngressWorkspaceOverrideForSpawnedRun({
|
||||
const sessionWorkspaceDir = resolveIngressWorkspaceOverrideForSessionRun({
|
||||
spawnedBy: sessionEntry?.spawnedBy,
|
||||
workspaceDir: sessionEntry?.spawnedWorkspaceDir,
|
||||
cwd: sessionEntry?.spawnedCwd,
|
||||
});
|
||||
const effectiveAgentWorkspaceDir = sessionWorkspaceDir ?? configuredWorkspaceDir;
|
||||
const directRuntimeCwd =
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ describe("method scope resolution", () => {
|
|||
["tasks.list", ["operator.read"]],
|
||||
["audit.list", ["operator.read"]],
|
||||
["tasks.get", ["operator.read"]],
|
||||
["taskSuggestions.list", ["operator.read"]],
|
||||
["taskSuggestions.create", ["operator.write"]],
|
||||
["taskSuggestions.accept", ["operator.admin"]],
|
||||
["taskSuggestions.dismiss", ["operator.write"]],
|
||||
["config.schema.lookup", ["operator.read"]],
|
||||
["sessions.create", ["operator.write"]],
|
||||
["sessions.send", ["operator.write"]],
|
||||
|
|
@ -205,6 +209,24 @@ describe("method scope resolution", () => {
|
|||
expect(isGatewayMethodClassified("sessions.patch")).toBe(true);
|
||||
});
|
||||
|
||||
it("requires admin only when sessions.create targets an explicit cwd", () => {
|
||||
expect(
|
||||
resolveLeastPrivilegeOperatorScopesForMethod("sessions.create", { worktree: true }),
|
||||
).toEqual(["operator.write"]);
|
||||
expect(
|
||||
resolveLeastPrivilegeOperatorScopesForMethod("sessions.create", {
|
||||
worktree: true,
|
||||
cwd: "/other/repo",
|
||||
}),
|
||||
).toEqual(["operator.admin"]);
|
||||
expect(
|
||||
authorizeOperatorScopesForMethod("sessions.create", ["operator.write"], {
|
||||
worktree: true,
|
||||
cwd: "/other/repo",
|
||||
}),
|
||||
).toEqual({ allowed: false, missingScope: "operator.admin" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["model", { key: "agent:main:ios-1", model: "anthropic/claude-sonnet-5" }],
|
||||
["sendPolicy", { key: "agent:main:ios-1", sendPolicy: "deny" }],
|
||||
|
|
|
|||
|
|
@ -98,6 +98,13 @@ function resolveSessionsPatchRequiredScopes(params: unknown): OperatorScope[] {
|
|||
return safeOnly ? [WRITE_SCOPE] : [ADMIN_SCOPE];
|
||||
}
|
||||
|
||||
function resolveSessionsCreateRequiredScopes(params: unknown): OperatorScope[] {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
return [WRITE_SCOPE];
|
||||
}
|
||||
return Object.hasOwn(params, "cwd") ? [ADMIN_SCOPE] : [WRITE_SCOPE];
|
||||
}
|
||||
|
||||
function resolveSessionActionRegisteredScopes(params: unknown): OperatorScope[] | undefined {
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
return undefined;
|
||||
|
|
@ -150,6 +157,9 @@ function resolveDynamicLeastPrivilegeOperatorScopesForMethod(
|
|||
if (method === "sessions.patch") {
|
||||
return resolveSessionsPatchRequiredScopes(params);
|
||||
}
|
||||
if (method === "sessions.create") {
|
||||
return resolveSessionsCreateRequiredScopes(params);
|
||||
}
|
||||
if (method === "sessions.delete") {
|
||||
return resolveSessionsDeleteRequiredScopes(params);
|
||||
}
|
||||
|
|
@ -220,6 +230,13 @@ export function authorizeOperatorScopesForMethod(
|
|||
return { allowed: true };
|
||||
}
|
||||
if (isDynamicOperatorGatewayMethod(method)) {
|
||||
if (method === "sessions.create") {
|
||||
const missingScope = findMissingOperatorScope(
|
||||
resolveSessionsCreateRequiredScopes(params),
|
||||
scopes,
|
||||
);
|
||||
return missingScope ? { allowed: false, missingScope } : { allowed: true };
|
||||
}
|
||||
if (method === "sessions.patch") {
|
||||
const missingScope = findMissingOperatorScope(
|
||||
resolveSessionsPatchRequiredScopes(params),
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
|||
{ name: "tasks.list", scope: "operator.read" },
|
||||
{ name: "tasks.get", scope: "operator.read" },
|
||||
{ name: "tasks.cancel", scope: "operator.write" },
|
||||
{ name: "taskSuggestions.list", scope: "operator.read" },
|
||||
{ name: "taskSuggestions.create", scope: "operator.write" },
|
||||
{ name: "taskSuggestions.accept", scope: "operator.admin" },
|
||||
{ name: "taskSuggestions.dismiss", scope: "operator.write" },
|
||||
{ name: "environments.list", scope: "operator.read" },
|
||||
{ name: "environments.status", scope: "operator.read" },
|
||||
{ name: "worktrees.list", scope: "operator.read" },
|
||||
|
|
@ -163,7 +167,8 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
|||
{ name: "sessions.compaction.get", scope: "operator.read" },
|
||||
{ name: "sessions.compaction.branch", scope: "operator.write" },
|
||||
{ name: "sessions.compaction.restore", scope: "operator.admin" },
|
||||
{ name: "sessions.create", scope: "operator.write", startup: true },
|
||||
// Params-aware: explicit cwd can point at any host checkout and requires admin.
|
||||
{ name: "sessions.create", scope: "dynamic", startup: true },
|
||||
{ name: "sessions.send", scope: "operator.write", startup: true },
|
||||
{ name: "sessions.abort", scope: "operator.write", startup: true },
|
||||
// Params-aware: write scope may mutate chat-organization fields
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
|
|||
"talk.event": [READ_SCOPE],
|
||||
"talk.mode": [WRITE_SCOPE],
|
||||
task: [READ_SCOPE],
|
||||
"task.suggestion": [READ_SCOPE],
|
||||
"update.available": [],
|
||||
"voicewake.changed": [READ_SCOPE],
|
||||
"voicewake.routing.changed": [READ_SCOPE],
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export const GATEWAY_EVENTS = [
|
|||
"heartbeat",
|
||||
"cron",
|
||||
"task",
|
||||
"task.suggestion",
|
||||
"node.pair.requested",
|
||||
"node.pair.resolved",
|
||||
"node.invoke.request",
|
||||
|
|
|
|||
|
|
@ -214,6 +214,10 @@ const loadTasksHandlers = lazyHandlerModule(
|
|||
() => import("./server-methods/tasks.js"),
|
||||
(module) => module.tasksHandlers,
|
||||
);
|
||||
const loadTaskSuggestionsHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/task-suggestions.js"),
|
||||
(module) => module.taskSuggestionsHandlers,
|
||||
);
|
||||
const loadToolsCatalogHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/tools-catalog.js"),
|
||||
(module) => module.toolsCatalogHandlers,
|
||||
|
|
@ -498,6 +502,15 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
|||
methods: ["tasks.list", "tasks.get", "tasks.cancel"],
|
||||
loadHandlers: loadTasksHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: [
|
||||
"taskSuggestions.list",
|
||||
"taskSuggestions.create",
|
||||
"taskSuggestions.accept",
|
||||
"taskSuggestions.dismiss",
|
||||
],
|
||||
loadHandlers: loadTaskSuggestionsHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: ["tools.catalog"],
|
||||
loadHandlers: loadToolsCatalogHandlers,
|
||||
|
|
|
|||
|
|
@ -3668,6 +3668,35 @@ describe("gateway agent handler", () => {
|
|||
expect(spawnedCall.cwd).toBe("/tmp/task-repo");
|
||||
});
|
||||
|
||||
it("uses a managed dashboard worktree as both workspace and runtime cwd", async () => {
|
||||
primeMainAgentRun();
|
||||
mockMainSessionEntry({ spawnedCwd: "/tmp/session-worktree" });
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
const store: Record<string, unknown> = {
|
||||
"agent:main:main": buildExistingMainStoreEntry({
|
||||
spawnedCwd: "/tmp/session-worktree",
|
||||
}),
|
||||
};
|
||||
return await updater(store);
|
||||
});
|
||||
mocks.agentCommand.mockClear();
|
||||
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "worktree run",
|
||||
sessionKey: "agent:main:main",
|
||||
idempotencyKey: "worktree-workspace-forwarded",
|
||||
},
|
||||
{ reqId: "worktree-workspace-forwarded-1" },
|
||||
);
|
||||
const worktreeCall = await waitForAgentCommandCall<{
|
||||
cwd?: string;
|
||||
workspaceDir?: string;
|
||||
}>();
|
||||
expect(worktreeCall.workspaceDir).toBe("/tmp/session-worktree");
|
||||
expect(worktreeCall.cwd).toBe("/tmp/session-worktree");
|
||||
});
|
||||
|
||||
it("keeps origin messageChannel as webchat while delivery channel uses last session channel", async () => {
|
||||
mockMainSessionEntry({
|
||||
sessionId: "existing-session-id",
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ import {
|
|||
} from "../../agents/run-timeout-attribution.js";
|
||||
import {
|
||||
normalizeSpawnedRunMetadata,
|
||||
resolveIngressWorkspaceOverrideForSpawnedRun,
|
||||
resolveIngressWorkspaceOverrideForSessionRun,
|
||||
} from "../../agents/spawned-context.js";
|
||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { agentCommandFromIngress } from "../../commands/agent.js";
|
||||
|
|
@ -3368,9 +3368,10 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||
}
|
||||
},
|
||||
// Internal-only: allow workspace override for spawned subagent runs.
|
||||
workspaceDir: resolveIngressWorkspaceOverrideForSpawnedRun({
|
||||
workspaceDir: resolveIngressWorkspaceOverrideForSessionRun({
|
||||
spawnedBy: spawnedByValue,
|
||||
workspaceDir: sessionEntry?.spawnedWorkspaceDir,
|
||||
cwd: sessionEntry?.spawnedCwd,
|
||||
}),
|
||||
cwd: resolveSessionRuntimeCwd({
|
||||
requestedCwd: request.cwd,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
import { readAcpSessionMeta } from "../../acp/runtime/session-meta.js";
|
||||
import { resolveModelAgentRuntimeMetadata } from "../../agents/agent-runtime-metadata.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js";
|
||||
import {
|
||||
abortEmbeddedAgentRun,
|
||||
isEmbeddedAgentRunActive,
|
||||
|
|
@ -1192,15 +1193,32 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||
const p = params;
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const initialMessage = resolveOptionalInitialSessionMessage(p);
|
||||
const requestedCwd = normalizeOptionalString(p.cwd);
|
||||
if (requestedCwd && p.worktree !== true) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create cwd requires worktree=true"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (requestedCwd && !path.isAbsolute(requestedCwd)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "sessions.create cwd must be absolute"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let sessionKey = p.key;
|
||||
let sessionAgentId = p.agentId;
|
||||
let sessionWorktree: Awaited<ReturnType<typeof managedWorktrees.create>> | undefined;
|
||||
let sessionCwd: string | undefined;
|
||||
let sessionSourceRoot: string | undefined;
|
||||
let provisionedSessionWorktree = false;
|
||||
if (p.worktree === true) {
|
||||
// Session worktrees stay at the method's operator.write bar: unlike worktrees.create,
|
||||
// this path never takes an arbitrary repoRoot — it only checks out the agent's own
|
||||
// configured workspace, the same repo chat runs already mutate for write-scope clients.
|
||||
// The normal path stays at operator.write and checks out the configured agent workspace.
|
||||
// An explicit cwd can target another host checkout, so method-scopes requires admin.
|
||||
const explicitKey = normalizeOptionalString(p.key);
|
||||
const requestedKey = explicitKey ?? "global";
|
||||
const requestedAgent = resolveRequestedGlobalAgentId(cfg, requestedKey, p.agentId);
|
||||
|
|
@ -1243,7 +1261,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||
const target = resolveGatewaySessionStoreTarget({ cfg, key: targetKey, agentId });
|
||||
sessionKey = preservesUnspecifiedKey ? undefined : targetKey;
|
||||
sessionAgentId = target.agentId;
|
||||
const workspace = resolveAgentWorkspaceDir(cfg, target.agentId);
|
||||
const workspace = requestedCwd ?? resolveAgentWorkspaceDir(cfg, target.agentId);
|
||||
// Subdirectory workspaces are valid: the worktree service resolves the repo root
|
||||
// via git discovery, so the preflight must accept ancestor .git entries too.
|
||||
if (!insideGitCheckout(workspace)) {
|
||||
|
|
@ -1255,6 +1273,8 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
const requestedRepository = await managedWorktrees.resolveRepositoryPaths(workspace);
|
||||
sessionSourceRoot = requestedRepository.sourceRoot;
|
||||
const existing = managedWorktrees.findLiveByOwner("session", target.canonicalKey);
|
||||
let existingDirectory = false;
|
||||
if (existing) {
|
||||
|
|
@ -1265,6 +1285,17 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||
}
|
||||
}
|
||||
if (existing && existingDirectory) {
|
||||
if (existing.repoRoot !== requestedRepository.canonicalRoot) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"session worktree belongs to a different repository",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
sessionWorktree = existing;
|
||||
} else {
|
||||
const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : [];
|
||||
|
|
@ -1288,7 +1319,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||
sessionCwd = sessionWorktree.path;
|
||||
try {
|
||||
const relative = path.relative(
|
||||
fs.realpathSync(sessionWorktree.repoRoot),
|
||||
sessionSourceRoot ?? fs.realpathSync(sessionWorktree.repoRoot),
|
||||
fs.realpathSync(workspace),
|
||||
);
|
||||
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
||||
|
|
@ -2817,7 +2848,11 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||
|
||||
const resolvedModel = resolveSessionModelRef(cfg, latestEntry, target.agentId);
|
||||
const workspaceDir =
|
||||
normalizeOptionalString(latestEntry.spawnedWorkspaceDir) ||
|
||||
resolveIngressWorkspaceOverrideForSessionRun({
|
||||
spawnedBy: latestEntry.spawnedBy,
|
||||
workspaceDir: latestEntry.spawnedWorkspaceDir,
|
||||
cwd: latestEntry.spawnedCwd,
|
||||
}) ??
|
||||
resolveAgentWorkspaceDir(cfg, target.agentId);
|
||||
const operationId = randomUUID();
|
||||
emitSessionOperation(context, {
|
||||
|
|
|
|||
401
src/gateway/server-methods/task-suggestions.test.ts
Normal file
401
src/gateway/server-methods/task-suggestions.test.ts
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { managedWorktrees } from "../../agents/worktrees/service.js";
|
||||
import {
|
||||
MAX_TASK_SUGGESTION_RETAINED_BYTES,
|
||||
beginTaskSuggestionAcceptance,
|
||||
resetTaskSuggestionsForTest,
|
||||
} from "../task-suggestion-registry.js";
|
||||
import { sessionsHandlers } from "./sessions.js";
|
||||
import { taskSuggestionsHandlers } from "./task-suggestions.js";
|
||||
import type { RespondFn } from "./types.js";
|
||||
|
||||
type Method =
|
||||
| "taskSuggestions.list"
|
||||
| "taskSuggestions.create"
|
||||
| "taskSuggestions.accept"
|
||||
| "taskSuggestions.dismiss";
|
||||
|
||||
async function call(method: Method, params: Record<string, unknown>) {
|
||||
const calls: Parameters<RespondFn>[] = [];
|
||||
const broadcast = vi.fn();
|
||||
const respond: RespondFn = (...args) => {
|
||||
calls.push(args);
|
||||
};
|
||||
await taskSuggestionsHandlers[method]?.({
|
||||
params,
|
||||
respond,
|
||||
context: { broadcast, getRuntimeConfig: () => ({}) },
|
||||
} as never);
|
||||
return { response: calls[0], broadcast };
|
||||
}
|
||||
|
||||
function requirePayload(result: Awaited<ReturnType<typeof call>>): unknown {
|
||||
expect(result.response?.[0]).toBe(true);
|
||||
if (!result.response?.[0]) {
|
||||
throw new Error("expected a successful gateway response");
|
||||
}
|
||||
return result.response[1];
|
||||
}
|
||||
|
||||
beforeEach(() => resetTaskSuggestionsForTest());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("task suggestion gateway methods", () => {
|
||||
it("creates, lists, and resolves an ephemeral suggestion", async () => {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: "Remove stale adapter",
|
||||
prompt: "Delete src/example.ts and update its tests.",
|
||||
tldr: "The adapter is unreachable and adds maintenance cost.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const payload = requirePayload(created) as { taskId: string };
|
||||
expect(payload.taskId).toMatch(/^task_/);
|
||||
expect(created.broadcast).toHaveBeenCalledWith(
|
||||
"task.suggestion",
|
||||
expect.objectContaining({
|
||||
action: "created",
|
||||
suggestion: expect.objectContaining({ agentId: "main" }),
|
||||
}),
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
|
||||
const listed = await call("taskSuggestions.list", {
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
expect(listed.response?.[1]).toMatchObject({
|
||||
suggestions: [{ id: payload.taskId, cwd: "/repo" }],
|
||||
});
|
||||
|
||||
const resolved = await call("taskSuggestions.dismiss", {
|
||||
taskId: payload.taskId,
|
||||
});
|
||||
expect(resolved.response?.[1]).toEqual({ taskId: payload.taskId, dismissed: true });
|
||||
expect(resolved.broadcast).toHaveBeenCalledWith(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId: payload.taskId, resolution: "dismissed" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
|
||||
const empty = await call("taskSuggestions.list", {});
|
||||
expect(empty.response?.[1]).toEqual({ suggestions: [] });
|
||||
});
|
||||
|
||||
it("accepts a suggestion once and replays the created session key", async () => {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: "Remove stale adapter",
|
||||
prompt: "Delete src/example.ts and update its tests.",
|
||||
tldr: "The adapter is unreachable and adds maintenance cost.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
const taskId = (requirePayload(created) as { taskId: string }).taskId;
|
||||
let sessionKey = "";
|
||||
const createSession = vi
|
||||
.spyOn(sessionsHandlers, "sessions.create")
|
||||
.mockImplementation(async ({ params, respond }) => {
|
||||
expect(params).toMatchObject({
|
||||
agentId: "main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
label: "Remove stale adapter",
|
||||
task: "Delete src/example.ts and update its tests.",
|
||||
worktree: true,
|
||||
cwd: "/repo",
|
||||
});
|
||||
sessionKey = (params as { key: string }).key;
|
||||
expect(sessionKey).toMatch(/^agent:main:dashboard:/);
|
||||
respond(true, { key: sessionKey, runStarted: true }, undefined);
|
||||
});
|
||||
|
||||
const first = await call("taskSuggestions.accept", { taskId });
|
||||
const retry = await call("taskSuggestions.accept", { taskId });
|
||||
|
||||
expect(first.response?.[1]).toEqual({ taskId, key: sessionKey });
|
||||
expect(retry.response?.[1]).toEqual({ taskId, key: sessionKey });
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(first.broadcast).toHaveBeenCalledWith(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId, resolution: "accepted" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("coalesces concurrent acceptance requests", async () => {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const taskId = (requirePayload(created) as { taskId: string }).taskId;
|
||||
let release: (() => void) | undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const createSession = vi
|
||||
.spyOn(sessionsHandlers, "sessions.create")
|
||||
.mockImplementation(async ({ params, respond }) => {
|
||||
await gate;
|
||||
respond(true, { key: (params as { key: string }).key, runStarted: true }, undefined);
|
||||
});
|
||||
|
||||
const first = call("taskSuggestions.accept", { taskId });
|
||||
await vi.waitFor(() => expect(createSession).toHaveBeenCalledTimes(1));
|
||||
const second = call("taskSuggestions.accept", { taskId });
|
||||
release?.();
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
|
||||
expect(firstResult.response?.[1]).toEqual(secondResult.response?.[1]);
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rolls back an empty session and keeps a failed seed suggestion pending", async () => {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
const taskId = (requirePayload(created) as { taskId: string }).taskId;
|
||||
let sessionKey = "";
|
||||
vi.spyOn(sessionsHandlers, "sessions.create").mockImplementation(
|
||||
async ({ params, respond }) => {
|
||||
sessionKey = (params as { key: string }).key;
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
key: sessionKey,
|
||||
runStarted: false,
|
||||
runError: { message: "provider unavailable" },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
const deleteSession = vi
|
||||
.spyOn(sessionsHandlers, "sessions.delete")
|
||||
.mockImplementation(async ({ params, respond }) => {
|
||||
expect(params).toEqual({
|
||||
key: sessionKey,
|
||||
agentId: "main",
|
||||
deleteTranscript: true,
|
||||
emitLifecycleHooks: false,
|
||||
});
|
||||
respond(true, { ok: true, deleted: true }, undefined);
|
||||
});
|
||||
|
||||
const accepted = await call("taskSuggestions.accept", { taskId });
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
|
||||
expect(accepted.response?.[0]).toBe(false);
|
||||
expect(accepted.response?.[2]).toMatchObject({ message: "provider unavailable" });
|
||||
expect(deleteSession).toHaveBeenCalledTimes(1);
|
||||
expect(accepted.broadcast).toHaveBeenCalledWith(
|
||||
"task.suggestion",
|
||||
expect.objectContaining({
|
||||
action: "created",
|
||||
suggestion: expect.objectContaining({ id: taskId }),
|
||||
}),
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
|
||||
});
|
||||
|
||||
it("rolls back a preallocated session when creation throws after persistence", async () => {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
const taskId = (requirePayload(created) as { taskId: string }).taskId;
|
||||
let sessionKey = "";
|
||||
vi.spyOn(sessionsHandlers, "sessions.create").mockImplementation(async ({ params }) => {
|
||||
sessionKey = (params as { key: string }).key;
|
||||
throw new Error("initial dispatch failed");
|
||||
});
|
||||
const deleteSession = vi
|
||||
.spyOn(sessionsHandlers, "sessions.delete")
|
||||
.mockImplementation(async ({ params, respond }) => {
|
||||
expect(params).toMatchObject({ key: sessionKey, agentId: "main" });
|
||||
respond(true, { ok: true, deleted: true }, undefined);
|
||||
});
|
||||
|
||||
const accepted = await call("taskSuggestions.accept", { taskId });
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
|
||||
expect(accepted.response?.[0]).toBe(false);
|
||||
expect(accepted.response?.[2]).toMatchObject({ message: "initial dispatch failed" });
|
||||
expect(deleteSession).toHaveBeenCalledTimes(1);
|
||||
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
|
||||
});
|
||||
|
||||
it("expires a suggestion when partial session rollback cannot finish", async () => {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
const taskId = (requirePayload(created) as { taskId: string }).taskId;
|
||||
vi.spyOn(sessionsHandlers, "sessions.create").mockRejectedValue(
|
||||
new Error("initial dispatch failed"),
|
||||
);
|
||||
vi.spyOn(sessionsHandlers, "sessions.delete").mockImplementation(async ({ respond }) => {
|
||||
respond(false, undefined, { code: "UNAVAILABLE", message: "still active" });
|
||||
});
|
||||
vi.spyOn(managedWorktrees, "findLiveByOwner").mockReturnValue({ id: "wt_partial" } as never);
|
||||
vi.spyOn(managedWorktrees, "remove").mockRejectedValue(new Error("still active"));
|
||||
|
||||
const accepted = await call("taskSuggestions.accept", { taskId });
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
|
||||
expect(accepted.response?.[0]).toBe(false);
|
||||
expect(accepted.response?.[2]?.message).toContain("failed to roll back");
|
||||
expect(accepted.broadcast).toHaveBeenCalledWith(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId, resolution: "expired" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
expect(listed.response?.[1]).toEqual({ suggestions: [] });
|
||||
});
|
||||
|
||||
it("rejects a relative cwd before recording or broadcasting", async () => {
|
||||
const result = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "relative/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
expect(result.response?.[0]).toBe(false);
|
||||
expect(result.response?.[2]).toMatchObject({ message: "task suggestion cwd must be absolute" });
|
||||
expect(result.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an agent that conflicts with the source session", async () => {
|
||||
const result = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "work",
|
||||
});
|
||||
|
||||
expect(result.response?.[0]).toBe(false);
|
||||
expect(result.response?.[2]).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "task suggestion agentId must match its source session",
|
||||
});
|
||||
expect(result.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects retained fields beyond their protocol limits", async () => {
|
||||
const result = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
prompt: "x".repeat(32_769),
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
expect(result.response?.[0]).toBe(false);
|
||||
expect(result.response?.[2]).toMatchObject({ code: "INVALID_REQUEST" });
|
||||
expect(result.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the complete list below the retained payload budget", async () => {
|
||||
const taskIds: string[] = [];
|
||||
for (let index = 0; index < 70; index += 1) {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: `Follow up ${index}`,
|
||||
prompt: `${index}: ${"x".repeat(32_760)}`,
|
||||
tldr: "The follow-up remains useful.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
taskIds.push((requirePayload(created) as { taskId: string }).taskId);
|
||||
}
|
||||
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
const payload = requirePayload(listed) as { suggestions: Array<{ id: string }> };
|
||||
expect(Buffer.byteLength(JSON.stringify(payload.suggestions))).toBeLessThanOrEqual(
|
||||
MAX_TASK_SUGGESTION_RETAINED_BYTES,
|
||||
);
|
||||
expect(payload.suggestions.length).toBeLessThan(70);
|
||||
expect(payload.suggestions.some((suggestion) => suggestion.id === taskIds[0])).toBe(false);
|
||||
});
|
||||
|
||||
it("broadcasts when the bounded registry expires a pending suggestion", async () => {
|
||||
const taskIds: string[] = [];
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: `Follow up ${index}`,
|
||||
prompt: `Complete follow-up task ${index}.`,
|
||||
tldr: `Follow-up task ${index} remains useful.`,
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
taskIds.push((requirePayload(created) as { taskId: string }).taskId);
|
||||
}
|
||||
|
||||
const replacement = await call("taskSuggestions.create", {
|
||||
title: "Latest follow up",
|
||||
prompt: "Complete the latest follow-up task.",
|
||||
tldr: "The latest follow-up remains useful.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
expect(replacement.response?.[0]).toBe(true);
|
||||
expect(replacement.broadcast).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId: taskIds[0], resolution: "expired" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
expect((requirePayload(listed) as { suggestions: unknown[] }).suggestions).toHaveLength(100);
|
||||
});
|
||||
|
||||
it("rejects a new suggestion when every bounded registry entry is accepting", async () => {
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: `Follow up ${index}`,
|
||||
prompt: `Complete follow-up task ${index}.`,
|
||||
tldr: `Follow-up task ${index} remains useful.`,
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const taskId = (requirePayload(created) as { taskId: string }).taskId;
|
||||
expect(beginTaskSuggestionAcceptance(taskId).status).toBe("claimed");
|
||||
}
|
||||
|
||||
const rejected = await call("taskSuggestions.create", {
|
||||
title: "One too many",
|
||||
prompt: "Complete one more follow-up task.",
|
||||
tldr: "This follow-up can wait until capacity returns.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
expect(rejected.response?.[0]).toBe(false);
|
||||
expect(rejected.response?.[2]).toMatchObject({
|
||||
code: "UNAVAILABLE",
|
||||
message: "task suggestion registry is busy",
|
||||
retryable: true,
|
||||
});
|
||||
expect(rejected.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
373
src/gateway/server-methods/task-suggestions.ts
Normal file
373
src/gateway/server-methods/task-suggestions.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
// Gateway methods for ephemeral model-proposed follow-up tasks.
|
||||
import path from "node:path";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
type TaskSuggestion,
|
||||
type TaskSuggestionsAcceptResult,
|
||||
validateTaskSuggestionsAcceptParams,
|
||||
validateTaskSuggestionsCreateParams,
|
||||
validateTaskSuggestionsDismissParams,
|
||||
validateTaskSuggestionsListParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { managedWorktrees } from "../../agents/worktrees/service.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { buildDashboardSessionKey } from "../session-create-service.js";
|
||||
import { loadSessionEntry } from "../session-utils.js";
|
||||
import {
|
||||
abandonTaskSuggestionAcceptance,
|
||||
beginTaskSuggestionAcceptance,
|
||||
cancelTaskSuggestionAcceptance,
|
||||
completeTaskSuggestionAcceptance,
|
||||
createTaskSuggestion,
|
||||
dismissTaskSuggestion,
|
||||
listTaskSuggestions,
|
||||
} from "../task-suggestion-registry.js";
|
||||
import { sessionsHandlers } from "./sessions.js";
|
||||
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
function invalidParams(method: string, errors: Parameters<typeof formatValidationErrors>[0]) {
|
||||
return errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid ${method} params: ${formatValidationErrors(errors)}`,
|
||||
);
|
||||
}
|
||||
|
||||
type TaskSuggestionAcceptanceResult =
|
||||
| { ok: true; result: TaskSuggestionsAcceptResult }
|
||||
| { ok: false; error: NonNullable<Parameters<RespondFn>[2]> };
|
||||
|
||||
const activeAcceptances = new Map<string, Promise<TaskSuggestionAcceptanceResult>>();
|
||||
|
||||
async function rollbackSuggestedTaskSession(params: {
|
||||
key: string;
|
||||
agentId?: string;
|
||||
options: GatewayRequestHandlerOptions;
|
||||
}): Promise<boolean> {
|
||||
let deletionConfirmed = false;
|
||||
try {
|
||||
await sessionsHandlers["sessions.delete"]?.({
|
||||
...params.options,
|
||||
params: {
|
||||
key: params.key,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
deleteTranscript: true,
|
||||
emitLifecycleHooks: false,
|
||||
},
|
||||
respond: (ok, payload) => {
|
||||
deletionConfirmed = Boolean(
|
||||
ok &&
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
typeof (payload as { deleted?: unknown }).deleted === "boolean",
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// The state probes below determine whether the preallocated session key
|
||||
// and its worktree were fully removed despite a handler-level failure.
|
||||
}
|
||||
try {
|
||||
if (!deletionConfirmed && loadSessionEntry(params.key, { agentId: params.agentId }).entry) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const worktree = managedWorktrees.findLiveByOwner("session", params.key);
|
||||
if (worktree) {
|
||||
try {
|
||||
await managedWorktrees.remove({
|
||||
id: worktree.id,
|
||||
reason: "suggested-task-seed-failed",
|
||||
force: true,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return managedWorktrees.findLiveByOwner("session", params.key) === undefined;
|
||||
}
|
||||
|
||||
async function failSuggestedTaskSession(params: {
|
||||
taskId: string;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
options: GatewayRequestHandlerOptions;
|
||||
error: NonNullable<Parameters<RespondFn>[2]>;
|
||||
}): Promise<TaskSuggestionAcceptanceResult> {
|
||||
const rolledBack = await rollbackSuggestedTaskSession({
|
||||
key: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
options: params.options,
|
||||
});
|
||||
if (rolledBack) {
|
||||
const restored = cancelTaskSuggestionAcceptance(params.taskId);
|
||||
if (restored) {
|
||||
params.options.context.broadcast(
|
||||
"task.suggestion",
|
||||
{ action: "created", suggestion: restored },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
}
|
||||
return { ok: false, error: params.error };
|
||||
}
|
||||
if (abandonTaskSuggestionAcceptance(params.taskId)) {
|
||||
params.options.context.broadcast(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId: params.taskId, resolution: "expired" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
`${params.error.message}; failed to roll back the partial suggested task session`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function createSuggestedTaskSession(params: {
|
||||
taskId: string;
|
||||
suggestion: TaskSuggestion;
|
||||
options: GatewayRequestHandlerOptions;
|
||||
}): Promise<TaskSuggestionAcceptanceResult> {
|
||||
let sessionResponse: Parameters<RespondFn> | undefined;
|
||||
const agentId = normalizeAgentId(
|
||||
params.suggestion.agentId ??
|
||||
parseAgentSessionKey(params.suggestion.sessionKey)?.agentId ??
|
||||
resolveDefaultAgentId(params.options.context.getRuntimeConfig()),
|
||||
);
|
||||
const sessionKey = buildDashboardSessionKey(agentId);
|
||||
try {
|
||||
await sessionsHandlers["sessions.create"]?.({
|
||||
...params.options,
|
||||
params: {
|
||||
key: sessionKey,
|
||||
agentId,
|
||||
parentSessionKey: params.suggestion.sessionKey,
|
||||
label: params.suggestion.title,
|
||||
task: params.suggestion.prompt,
|
||||
worktree: true,
|
||||
cwd: params.suggestion.cwd,
|
||||
},
|
||||
respond: (...args) => {
|
||||
sessionResponse = args;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return await failSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
sessionKey,
|
||||
agentId,
|
||||
options: params.options,
|
||||
error: errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)),
|
||||
});
|
||||
}
|
||||
if (!sessionResponse) {
|
||||
return await failSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
sessionKey,
|
||||
agentId,
|
||||
options: params.options,
|
||||
error: errorShape(ErrorCodes.UNAVAILABLE, "sessions.create did not respond"),
|
||||
});
|
||||
}
|
||||
const [ok, payload, error] = sessionResponse;
|
||||
if (!ok) {
|
||||
return await failSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
sessionKey,
|
||||
agentId,
|
||||
options: params.options,
|
||||
error: error ?? errorShape(ErrorCodes.UNAVAILABLE, "failed to create suggested task"),
|
||||
});
|
||||
}
|
||||
const key =
|
||||
payload && typeof payload === "object" && typeof (payload as { key?: unknown }).key === "string"
|
||||
? (payload as { key: string }).key
|
||||
: undefined;
|
||||
if (!key) {
|
||||
return await failSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
sessionKey,
|
||||
agentId,
|
||||
options: params.options,
|
||||
error: errorShape(ErrorCodes.UNAVAILABLE, "sessions.create returned no session key"),
|
||||
});
|
||||
}
|
||||
const result = payload as { runError?: unknown; runStarted?: unknown };
|
||||
if (result.runStarted !== true) {
|
||||
const runMessage =
|
||||
result.runError &&
|
||||
typeof result.runError === "object" &&
|
||||
typeof (result.runError as { message?: unknown }).message === "string"
|
||||
? (result.runError as { message: string }).message
|
||||
: "initial task did not start";
|
||||
return await failSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
sessionKey: key,
|
||||
agentId,
|
||||
options: params.options,
|
||||
error: errorShape(ErrorCodes.UNAVAILABLE, runMessage),
|
||||
});
|
||||
}
|
||||
completeTaskSuggestionAcceptance(params.taskId, key);
|
||||
params.options.context.broadcast(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId: params.taskId, resolution: "accepted" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
return { ok: true, result: { taskId: params.taskId, key } };
|
||||
}
|
||||
|
||||
export const taskSuggestionsHandlers: GatewayRequestHandlers = {
|
||||
"taskSuggestions.list": ({ params, respond }) => {
|
||||
if (!validateTaskSuggestionsListParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
invalidParams("taskSuggestions.list", validateTaskSuggestionsListParams.errors),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, { suggestions: listTaskSuggestions(params) }, undefined);
|
||||
},
|
||||
"taskSuggestions.create": ({ params, respond, context }) => {
|
||||
if (!validateTaskSuggestionsCreateParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
invalidParams("taskSuggestions.create", validateTaskSuggestionsCreateParams.errors),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!path.isAbsolute(params.cwd)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "task suggestion cwd must be absolute"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sessionAgentId = parseAgentSessionKey(params.sessionKey)?.agentId;
|
||||
const requestedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
|
||||
if (
|
||||
requestedAgentId &&
|
||||
sessionAgentId &&
|
||||
requestedAgentId !== normalizeAgentId(sessionAgentId)
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"task suggestion agentId must match its source session",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const agentId = normalizeAgentId(
|
||||
requestedAgentId ?? sessionAgentId ?? resolveDefaultAgentId(context.getRuntimeConfig()),
|
||||
);
|
||||
const created = createTaskSuggestion({ ...params, agentId });
|
||||
if (created.status === "full") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "task suggestion registry is busy", {
|
||||
retryable: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { suggestion } = created;
|
||||
// The registry is ephemeral; live events keep open Control UI tabs in sync
|
||||
// without turning suggestions into durable task state.
|
||||
for (const taskId of created.evictedPendingTaskIds) {
|
||||
context.broadcast(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId, resolution: "expired" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
}
|
||||
context.broadcast("task.suggestion", { action: "created", suggestion }, { dropIfSlow: true });
|
||||
respond(true, { taskId: suggestion.id, suggestion }, undefined);
|
||||
},
|
||||
"taskSuggestions.accept": async (options) => {
|
||||
const { params, respond } = options;
|
||||
if (!validateTaskSuggestionsAcceptParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
invalidParams("taskSuggestions.accept", validateTaskSuggestionsAcceptParams.errors),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const active = activeAcceptances.get(params.taskId);
|
||||
if (active) {
|
||||
const outcome = await active;
|
||||
respond(
|
||||
outcome.ok,
|
||||
outcome.ok ? outcome.result : undefined,
|
||||
outcome.ok ? undefined : outcome.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const acceptance = beginTaskSuggestionAcceptance(params.taskId);
|
||||
if (acceptance.status === "accepted") {
|
||||
respond(true, { taskId: params.taskId, key: acceptance.sessionKey }, undefined);
|
||||
return;
|
||||
}
|
||||
if (acceptance.status !== "claimed") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
acceptance.status === "accepting" ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST,
|
||||
`task suggestion cannot be accepted: ${acceptance.status}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pending = createSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
suggestion: acceptance.suggestion,
|
||||
options,
|
||||
});
|
||||
activeAcceptances.set(params.taskId, pending);
|
||||
try {
|
||||
const outcome = await pending;
|
||||
respond(
|
||||
outcome.ok,
|
||||
outcome.ok ? outcome.result : undefined,
|
||||
outcome.ok ? undefined : outcome.error,
|
||||
);
|
||||
} finally {
|
||||
activeAcceptances.delete(params.taskId);
|
||||
}
|
||||
},
|
||||
"taskSuggestions.dismiss": ({ params, respond, context }) => {
|
||||
if (!validateTaskSuggestionsDismissParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
invalidParams("taskSuggestions.dismiss", validateTaskSuggestionsDismissParams.errors),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const dismissed = dismissTaskSuggestion(params.taskId);
|
||||
if (dismissed) {
|
||||
context.broadcast(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId: params.taskId, resolution: "dismissed" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
}
|
||||
respond(true, { taskId: params.taskId, dismissed }, undefined);
|
||||
},
|
||||
};
|
||||
|
|
@ -521,7 +521,7 @@ test("sessions.compact without maxLines runs embedded manual compaction for chec
|
|||
throw new Error("expected embedded compaction session file");
|
||||
}
|
||||
expect(path.basename(compactionCall.sessionFile)).toBe("sess-main.jsonl");
|
||||
expect(compactionCall.workspaceDir).toBe(path.join(os.tmpdir(), "openclaw-gateway-test"));
|
||||
expect(compactionCall.workspaceDir).toBe("/tmp/task-repo");
|
||||
expect(compactionCall.cwd).toBe("/tmp/task-repo");
|
||||
expect(callConfig.agents?.defaults?.model?.primary).toBe("anthropic/claude-opus-4-6");
|
||||
expect(callConfig.agents?.defaults?.workspace).toBe(
|
||||
|
|
|
|||
|
|
@ -122,7 +122,10 @@ test("sessions.create provisions and reuses a session worktree for later runs",
|
|||
});
|
||||
expect(run.ok).toBe(true);
|
||||
await vi.waitFor(() => expect(agentCommand).toHaveBeenCalled());
|
||||
expect(agentCommand.mock.calls.at(-1)?.[0]).toMatchObject({ cwd: worktree?.path });
|
||||
expect(agentCommand.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
cwd: worktree?.path,
|
||||
workspaceDir: worktree?.path,
|
||||
});
|
||||
ws.close();
|
||||
} finally {
|
||||
if (worktreeId) {
|
||||
|
|
@ -139,6 +142,83 @@ test("sessions.create provisions and reuses a session worktree for later runs",
|
|||
}
|
||||
});
|
||||
|
||||
test("sessions.create provisions a worktree from an admin-selected cwd", async () => {
|
||||
const configuredRoot = await fs.mkdtemp(
|
||||
path.join(await fs.realpath(os.tmpdir()), "openclaw-configured-workspace-"),
|
||||
);
|
||||
const selectedRoot = await fs.mkdtemp(
|
||||
path.join(await fs.realpath(os.tmpdir()), "openclaw-selected-workspace-"),
|
||||
);
|
||||
const configuredWorkspace = await initializeGitWorkspace(configuredRoot);
|
||||
const selectedWorkspace = await initializeGitWorkspace(selectedRoot);
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = path.join(configuredRoot, "state");
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
testState.agentConfig = { workspace: configuredWorkspace };
|
||||
await createSessionStoreDir();
|
||||
let worktreeId: string | undefined;
|
||||
try {
|
||||
const created = await directSessionReq<{
|
||||
key: string;
|
||||
entry: { spawnedCwd?: string };
|
||||
worktree: { id: string; path: string };
|
||||
}>(
|
||||
"sessions.create",
|
||||
{ agentId: "main", worktree: true, cwd: selectedWorkspace },
|
||||
{ client: { connect: { scopes: ["operator.admin"] } } as never },
|
||||
);
|
||||
|
||||
expect(created.ok).toBe(true);
|
||||
const worktree = created.payload?.worktree;
|
||||
worktreeId = worktree?.id;
|
||||
expect(created.payload?.entry.spawnedCwd).toBe(worktree?.path);
|
||||
expect(
|
||||
findLiveRegistryWorktreeByOwner(process.env, "session", created.payload?.key ?? ""),
|
||||
).toMatchObject({
|
||||
id: worktree?.id,
|
||||
repoRoot: selectedWorkspace,
|
||||
});
|
||||
|
||||
const mismatched = await directSessionReq(
|
||||
"sessions.create",
|
||||
{
|
||||
key: created.payload?.key,
|
||||
agentId: "main",
|
||||
worktree: true,
|
||||
cwd: configuredWorkspace,
|
||||
},
|
||||
{ client: { connect: { scopes: ["operator.admin"] } } as never },
|
||||
);
|
||||
expect(mismatched).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: "session worktree belongs to a different repository" },
|
||||
});
|
||||
} finally {
|
||||
if (worktreeId) {
|
||||
await managedWorktrees.remove({ id: worktreeId, reason: "test-cleanup", force: true });
|
||||
}
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
testState.agentConfig = undefined;
|
||||
await fs.rm(configuredRoot, { recursive: true, force: true });
|
||||
await fs.rm(selectedRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("sessions.create rejects cwd without a managed worktree", async () => {
|
||||
const created = await directSessionReq("sessions.create", { cwd: "/tmp/repo" });
|
||||
|
||||
expect(created.ok).toBe(false);
|
||||
expect(created.error).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "sessions.create cwd requires worktree=true",
|
||||
});
|
||||
});
|
||||
|
||||
test("sessions.create skips the worktree setup script for non-admin callers", async () => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(await fs.realpath(os.tmpdir()), "openclaw-worktree-setup-scope-"),
|
||||
|
|
@ -183,12 +263,14 @@ test("sessions.create skips the worktree setup script for non-admin callers", as
|
|||
}
|
||||
});
|
||||
|
||||
test("sessions.create accepts a workspace configured as a repo subdirectory", async () => {
|
||||
test("sessions.create preserves a linked-worktree subdirectory", async () => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(await fs.realpath(os.tmpdir()), "openclaw-subdir-session-worktree-"),
|
||||
);
|
||||
const repoRoot = await initializeGitWorkspace(root);
|
||||
const workspace = path.join(repoRoot, "packages", "app");
|
||||
const linkedRoot = path.join(root, "linked");
|
||||
await execFileAsync("git", ["-C", repoRoot, "worktree", "add", "-b", "linked", linkedRoot]);
|
||||
const workspace = path.join(linkedRoot, "packages", "app");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = path.join(root, "state");
|
||||
|
|
|
|||
|
|
@ -985,6 +985,42 @@ test("sessions.compact passes the selected global agent into embedded compaction
|
|||
await resetConfiguredGlobalAgentSessionStore(globalStores);
|
||||
});
|
||||
|
||||
test("sessions.compact mounts a dashboard managed worktree as its workspace", async () => {
|
||||
const { dir } = await createSessionStoreDir();
|
||||
const sessionFile = path.join(dir, "sess-suggested.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
createLinearSessionTranscript("sess-suggested", ["one", "two"]),
|
||||
"utf-8",
|
||||
);
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
"dashboard:suggested": sessionStoreEntry("sess-suggested", {
|
||||
sessionFile,
|
||||
spawnedCwd: "/tmp/suggested-worktree",
|
||||
}),
|
||||
},
|
||||
});
|
||||
const { getRuntimeConfig } = await getGatewayConfigModule();
|
||||
|
||||
const { responsePayload } = await invokeSessionsCompact({
|
||||
getRuntimeConfig,
|
||||
params: { key: "agent:main:dashboard:suggested" },
|
||||
subscribedConnIds: new Set(),
|
||||
});
|
||||
|
||||
expect(embeddedRunMock.compactEmbeddedAgentSession).toHaveBeenCalledTimes(1);
|
||||
expectFields(responsePayload, {
|
||||
ok: true,
|
||||
key: "agent:main:dashboard:suggested",
|
||||
compacted: true,
|
||||
});
|
||||
expect(embeddedRunMock.compactEmbeddedAgentSession.mock.calls[0]?.[0]).toMatchObject({
|
||||
workspaceDir: "/tmp/suggested-worktree",
|
||||
cwd: "/tmp/suggested-worktree",
|
||||
});
|
||||
});
|
||||
|
||||
test("sessions.changed mutation events include subagent ownership metadata", async () => {
|
||||
await createSessionStoreDir();
|
||||
await writeSessionStore({
|
||||
|
|
|
|||
157
src/gateway/task-suggestion-registry.ts
Normal file
157
src/gateway/task-suggestion-registry.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// Process-local registry for model-proposed follow-up tasks.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
TaskSuggestion,
|
||||
TaskSuggestionsCreateParams,
|
||||
TaskSuggestionsListParams,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
const MAX_TASK_SUGGESTIONS = 100;
|
||||
export const MAX_TASK_SUGGESTION_RETAINED_BYTES = 2 * 1024 * 1024;
|
||||
type TaskSuggestionRecord =
|
||||
| { status: "pending" | "accepting" | "dismissed"; suggestion: TaskSuggestion }
|
||||
| { status: "accepted"; suggestion: TaskSuggestion; sessionKey: string };
|
||||
|
||||
const suggestions = new Map<string, TaskSuggestionRecord>();
|
||||
let retainedSuggestionBytes = 0;
|
||||
|
||||
function retainedBytesForSuggestion(suggestion: TaskSuggestion): number {
|
||||
// Account for one array delimiter per record. Keeping one extra byte free
|
||||
// covers the surrounding brackets in the list response.
|
||||
return Buffer.byteLength(JSON.stringify(suggestion)) + 1;
|
||||
}
|
||||
|
||||
export type CreateTaskSuggestionResult =
|
||||
| { status: "created"; suggestion: TaskSuggestion; evictedPendingTaskIds: string[] }
|
||||
| { status: "full" };
|
||||
|
||||
function evictTaskSuggestion(): string | null | undefined {
|
||||
for (const [taskId, record] of suggestions) {
|
||||
if (record.status === "accepted" || record.status === "dismissed") {
|
||||
retainedSuggestionBytes -= retainedBytesForSuggestion(record.suggestion);
|
||||
suggestions.delete(taskId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
for (const [taskId, record] of suggestions) {
|
||||
if (record.status === "pending") {
|
||||
retainedSuggestionBytes -= retainedBytesForSuggestion(record.suggestion);
|
||||
suggestions.delete(taskId);
|
||||
return taskId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Records one suggestion without starting work. IDs intentionally vanish on restart. */
|
||||
export function createTaskSuggestion(
|
||||
params: TaskSuggestionsCreateParams,
|
||||
): CreateTaskSuggestionResult {
|
||||
const suggestion: TaskSuggestion = {
|
||||
id: `task_${randomUUID()}`,
|
||||
title: params.title,
|
||||
prompt: params.prompt,
|
||||
tldr: params.tldr,
|
||||
cwd: params.cwd,
|
||||
sessionKey: params.sessionKey,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const suggestionBytes = retainedBytesForSuggestion(suggestion);
|
||||
const evictedPendingTaskIds: string[] = [];
|
||||
while (
|
||||
suggestions.size >= MAX_TASK_SUGGESTIONS ||
|
||||
retainedSuggestionBytes + suggestionBytes + 1 > MAX_TASK_SUGGESTION_RETAINED_BYTES
|
||||
) {
|
||||
const evictedTaskId = evictTaskSuggestion();
|
||||
if (evictedTaskId === undefined) {
|
||||
// All retained tasks are in-flight acceptances. Reject new work instead
|
||||
// of losing either its UI card or an acceptance's idempotency result.
|
||||
return { status: "full" };
|
||||
}
|
||||
if (evictedTaskId) {
|
||||
evictedPendingTaskIds.push(evictedTaskId);
|
||||
}
|
||||
}
|
||||
suggestions.set(suggestion.id, { status: "pending", suggestion });
|
||||
retainedSuggestionBytes += suggestionBytes;
|
||||
return { status: "created", suggestion, evictedPendingTaskIds };
|
||||
}
|
||||
|
||||
/** Lists newest suggestions first, optionally scoped to their source chat. */
|
||||
export function listTaskSuggestions(params: TaskSuggestionsListParams): TaskSuggestion[] {
|
||||
return [...suggestions.values()]
|
||||
.filter((record) => record.status === "pending")
|
||||
.map((record) => record.suggestion)
|
||||
.filter(
|
||||
(suggestion) =>
|
||||
(!params.sessionKey || suggestion.sessionKey === params.sessionKey) &&
|
||||
(!params.agentId || suggestion.agentId === params.agentId),
|
||||
)
|
||||
.toReversed();
|
||||
}
|
||||
|
||||
export type TaskSuggestionAcceptance =
|
||||
| { status: "claimed"; suggestion: TaskSuggestion }
|
||||
| { status: "accepted"; sessionKey: string }
|
||||
| { status: "accepting" | "dismissed" | "missing" };
|
||||
|
||||
/** Claims one suggestion before any privileged worktree/session side effects begin. */
|
||||
export function beginTaskSuggestionAcceptance(taskId: string): TaskSuggestionAcceptance {
|
||||
const record = suggestions.get(taskId);
|
||||
if (!record) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
if (record.status === "accepted") {
|
||||
return { status: "accepted", sessionKey: record.sessionKey };
|
||||
}
|
||||
if (record.status !== "pending") {
|
||||
return { status: record.status };
|
||||
}
|
||||
suggestions.set(taskId, { status: "accepting", suggestion: record.suggestion });
|
||||
return { status: "claimed", suggestion: record.suggestion };
|
||||
}
|
||||
|
||||
/** Restores a claim when session creation fails before an acceptance result exists. */
|
||||
export function cancelTaskSuggestionAcceptance(taskId: string): TaskSuggestion | undefined {
|
||||
const record = suggestions.get(taskId);
|
||||
if (record?.status === "accepting") {
|
||||
suggestions.set(taskId, { status: "pending", suggestion: record.suggestion });
|
||||
return record.suggestion;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Retires a claimed suggestion when partial side effects cannot be rolled back safely. */
|
||||
export function abandonTaskSuggestionAcceptance(taskId: string): boolean {
|
||||
const record = suggestions.get(taskId);
|
||||
if (record?.status !== "accepting") {
|
||||
return false;
|
||||
}
|
||||
suggestions.set(taskId, { status: "dismissed", suggestion: record.suggestion });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Retains the created session key so retries return the same accepted task. */
|
||||
export function completeTaskSuggestionAcceptance(taskId: string, sessionKey: string): void {
|
||||
const record = suggestions.get(taskId);
|
||||
if (record?.status === "accepting") {
|
||||
suggestions.set(taskId, { status: "accepted", suggestion: record.suggestion, sessionKey });
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismisses only a pending suggestion; accepted or in-flight tasks stay immutable. */
|
||||
export function dismissTaskSuggestion(taskId: string): boolean {
|
||||
const record = suggestions.get(taskId);
|
||||
if (record?.status !== "pending") {
|
||||
return false;
|
||||
}
|
||||
suggestions.set(taskId, { status: "dismissed", suggestion: record.suggestion });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Test-only reset for the intentionally process-local registry. */
|
||||
export function resetTaskSuggestionsForTest(): void {
|
||||
suggestions.clear();
|
||||
retainedSuggestionBytes = 0;
|
||||
}
|
||||
|
|
@ -1217,6 +1217,159 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("starts a model-suggested follow-up in a fresh worktree session", async () => {
|
||||
const context = await newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const suggestion = {
|
||||
id: "task_123",
|
||||
title: "Remove stale adapter",
|
||||
prompt: "Delete the stale adapter in src/example.ts and update tests.",
|
||||
tldr: "The adapter is unreachable and adds maintenance cost.",
|
||||
cwd: "/projects/example",
|
||||
sessionKey: "main",
|
||||
agentId: "main",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const gateway = await installMockGateway(page, {
|
||||
deferredMethods: ["taskSuggestions.list"],
|
||||
featureMethods: [
|
||||
"chat.metadata",
|
||||
"chat.startup",
|
||||
"taskSuggestions.list",
|
||||
"taskSuggestions.accept",
|
||||
],
|
||||
methodResponses: {
|
||||
"taskSuggestions.list": { suggestions: [suggestion] },
|
||||
"taskSuggestions.accept": {
|
||||
taskId: "task_123",
|
||||
key: "agent:main:dashboard:suggested",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await gateway.waitForRequest("taskSuggestions.list");
|
||||
await gateway.emitGatewayEvent("task.suggestion", {
|
||||
action: "created",
|
||||
suggestion,
|
||||
});
|
||||
await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] });
|
||||
|
||||
const startButton = page.getByRole("button", { name: "Start in worktree" });
|
||||
await startButton.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await page
|
||||
.getByText("/projects/example", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await page
|
||||
.getByText("Delete the stale adapter in src/example.ts and update tests.", {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await startButton.click();
|
||||
|
||||
const acceptRequest = await gateway.waitForRequest("taskSuggestions.accept");
|
||||
expect(acceptRequest.params).toEqual({ taskId: "task_123" });
|
||||
} finally {
|
||||
await closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("clears model-suggested follow-ups while switching sessions", async () => {
|
||||
const context = await newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"],
|
||||
methodResponses: {
|
||||
"sessions.list": chatSessionListResponse(),
|
||||
"taskSuggestions.list": {
|
||||
suggestions: [
|
||||
{
|
||||
id: "task_session_a",
|
||||
title: "Follow up from session A",
|
||||
prompt: "Complete the follow-up discovered in session A.",
|
||||
tldr: "This suggestion belongs only to session A.",
|
||||
cwd: "/projects/example",
|
||||
sessionKey: "agent:main:session-a",
|
||||
agentId: "main",
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sessionKey: "agent:main:session-a",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
const startButton = page.getByRole("button", { name: "Start in worktree" });
|
||||
await startButton.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await gateway.deferNext("taskSuggestions.list");
|
||||
await page
|
||||
.locator(
|
||||
'.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link',
|
||||
)
|
||||
.click();
|
||||
await waitForRequests(gateway, "taskSuggestions.list", 2);
|
||||
|
||||
await expect.poll(() => startButton.count()).toBe(0);
|
||||
await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] });
|
||||
} finally {
|
||||
await closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the composer visible when follow-up suggestions overflow", async () => {
|
||||
const context = await newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 720, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"],
|
||||
methodResponses: {
|
||||
"taskSuggestions.list": {
|
||||
suggestions: Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `task_overflow_${index}`,
|
||||
title: `Follow-up ${index}`,
|
||||
prompt: "Inspect the related implementation and tests. ".repeat(12),
|
||||
tldr: "This follow-up remains useful but must not hide the composer.",
|
||||
cwd: "/projects/example",
|
||||
sessionKey: "main",
|
||||
agentId: "main",
|
||||
createdAt: Date.now() + index,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
const tray = page.locator(".task-suggestions");
|
||||
await tray.waitFor({ state: "visible", timeout: 10_000 });
|
||||
expect(await tray.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const composer = page.locator(".agent-chat__composer-shell");
|
||||
await composer.waitFor({ state: "visible", timeout: 10_000 });
|
||||
const box = await composer.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect((box?.y ?? 720) + (box?.height ?? 0)).toBeLessThanOrEqual(720);
|
||||
} finally {
|
||||
await closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("sends the first chat turn while agents startup loading is still pending", async () => {
|
||||
const context = await newBrowserContext({
|
||||
locale: "en-US",
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/ar.meta.json
generated
8
ui/src/i18n/.i18n/ar.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:18.329Z",
|
||||
"generatedAt": "2026-07-09T02:23:54.830Z",
|
||||
"locale": "ar",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/de.meta.json
generated
8
ui/src/i18n/.i18n/de.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:17.592Z",
|
||||
"generatedAt": "2026-07-09T02:23:54.190Z",
|
||||
"locale": "de",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/es.meta.json
generated
8
ui/src/i18n/.i18n/es.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:17.716Z",
|
||||
"generatedAt": "2026-07-09T02:23:54.294Z",
|
||||
"locale": "es",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/fa.meta.json
generated
8
ui/src/i18n/.i18n/fa.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:19.480Z",
|
||||
"generatedAt": "2026-07-09T02:23:55.791Z",
|
||||
"locale": "fa",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/fr.meta.json
generated
8
ui/src/i18n/.i18n/fr.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:18.082Z",
|
||||
"generatedAt": "2026-07-09T02:23:54.618Z",
|
||||
"locale": "fr",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/hi.meta.json
generated
8
ui/src/i18n/.i18n/hi.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:18.205Z",
|
||||
"generatedAt": "2026-07-09T02:23:54.719Z",
|
||||
"locale": "hi",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/id.meta.json
generated
8
ui/src/i18n/.i18n/id.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:18.814Z",
|
||||
"generatedAt": "2026-07-09T02:23:55.260Z",
|
||||
"locale": "id",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/it.meta.json
generated
8
ui/src/i18n/.i18n/it.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:18.447Z",
|
||||
"generatedAt": "2026-07-09T02:23:54.937Z",
|
||||
"locale": "it",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/ja-JP.meta.json
generated
8
ui/src/i18n/.i18n/ja-JP.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:17.838Z",
|
||||
"generatedAt": "2026-07-09T02:23:54.400Z",
|
||||
"locale": "ja-JP",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/ko.meta.json
generated
8
ui/src/i18n/.i18n/ko.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:17.959Z",
|
||||
"generatedAt": "2026-07-09T02:23:54.509Z",
|
||||
"locale": "ko",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/nl.meta.json
generated
8
ui/src/i18n/.i18n/nl.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:19.296Z",
|
||||
"generatedAt": "2026-07-09T02:23:55.688Z",
|
||||
"locale": "nl",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/pl.meta.json
generated
8
ui/src/i18n/.i18n/pl.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:18.930Z",
|
||||
"generatedAt": "2026-07-09T02:23:55.361Z",
|
||||
"locale": "pl",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/pt-BR.meta.json
generated
8
ui/src/i18n/.i18n/pt-BR.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:17.472Z",
|
||||
"generatedAt": "2026-07-09T02:23:54.091Z",
|
||||
"locale": "pt-BR",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/ru.meta.json
generated
8
ui/src/i18n/.i18n/ru.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:19.608Z",
|
||||
"generatedAt": "2026-07-09T02:23:55.899Z",
|
||||
"locale": "ru",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/th.meta.json
generated
8
ui/src/i18n/.i18n/th.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:19.058Z",
|
||||
"generatedAt": "2026-07-09T02:23:55.469Z",
|
||||
"locale": "th",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/tr.meta.json
generated
8
ui/src/i18n/.i18n/tr.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:18.569Z",
|
||||
"generatedAt": "2026-07-09T02:23:55.051Z",
|
||||
"locale": "tr",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/uk.meta.json
generated
8
ui/src/i18n/.i18n/uk.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:18.689Z",
|
||||
"generatedAt": "2026-07-09T02:23:55.154Z",
|
||||
"locale": "uk",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/vi.meta.json
generated
8
ui/src/i18n/.i18n/vi.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:19.179Z",
|
||||
"generatedAt": "2026-07-09T02:23:55.576Z",
|
||||
"locale": "vi",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/zh-CN.meta.json
generated
8
ui/src/i18n/.i18n/zh-CN.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:17.217Z",
|
||||
"generatedAt": "2026-07-09T02:23:53.872Z",
|
||||
"locale": "zh-CN",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
8
ui/src/i18n/.i18n/zh-TW.meta.json
generated
8
ui/src/i18n/.i18n/zh-TW.meta.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-08T13:50:17.351Z",
|
||||
"generatedAt": "2026-07-09T02:23:53.982Z",
|
||||
"locale": "zh-TW",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "46a510a0c1922e82db6836aedafbded1b740535fc1aa22071f5398692b5aeee9",
|
||||
"totalKeys": 1669,
|
||||
"translatedKeys": 1669,
|
||||
"sourceHash": "ef47733c644c36f1edaca392ff9cc2997ad9ae3ebb143371684bf16c204b7f9c",
|
||||
"totalKeys": 1676,
|
||||
"translatedKeys": 1676,
|
||||
"workflow": 1
|
||||
}
|
||||
|
|
|
|||
9
ui/src/i18n/locales/ar.ts
generated
9
ui/src/i18n/locales/ar.ts
generated
|
|
@ -1539,6 +1539,15 @@ export const ar: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "تم قطع الاتصال بـ Gateway.",
|
||||
archivedSessionDisabled: "استعِد هذه الجلسة لإرسال الرسائل.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "متابعة مقترحة",
|
||||
start: "البدء في شجرة عمل",
|
||||
starting: "جارٍ البدء…",
|
||||
dismiss: "تجاهل {title}",
|
||||
project: "المشروع",
|
||||
instructions: "التعليمات",
|
||||
adminRequired: "يلزم وصول المسؤول لإنشاء شجرة عمل من هذا المشروع.",
|
||||
},
|
||||
refreshTitle: "تحديث بيانات الدردشة",
|
||||
settings: "إعدادات الدردشة",
|
||||
usageRemaining: "الاستخدام المتبقي",
|
||||
|
|
|
|||
10
ui/src/i18n/locales/de.ts
generated
10
ui/src/i18n/locales/de.ts
generated
|
|
@ -1569,6 +1569,16 @@ export const de: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Verbindung zum Gateway getrennt.",
|
||||
archivedSessionDisabled: "Stellen Sie diese Sitzung wieder her, um Nachrichten zu senden.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Vorgeschlagene Folgeaufgabe",
|
||||
start: "In Worktree starten",
|
||||
starting: "Wird gestartet…",
|
||||
dismiss: "{title} verwerfen",
|
||||
project: "Projekt",
|
||||
instructions: "Anweisungen",
|
||||
adminRequired:
|
||||
"Administratorzugriff ist erforderlich, um einen Worktree für dieses Projekt zu erstellen.",
|
||||
},
|
||||
refreshTitle: "Chat-Daten aktualisieren",
|
||||
settings: "Chat-Einstellungen",
|
||||
usageRemaining: "Verbleibende Nutzung",
|
||||
|
|
|
|||
|
|
@ -1545,6 +1545,15 @@ export const en: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Disconnected from gateway.",
|
||||
archivedSessionDisabled: "Restore this session to send messages.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Suggested follow-up",
|
||||
start: "Start in worktree",
|
||||
starting: "Starting…",
|
||||
dismiss: "Dismiss {title}",
|
||||
project: "Project",
|
||||
instructions: "Instructions",
|
||||
adminRequired: "Administrator access is required to create a worktree from this project.",
|
||||
},
|
||||
refreshTitle: "Refresh chat data",
|
||||
settings: "Chat settings",
|
||||
usageRemaining: "Usage Remaining",
|
||||
|
|
|
|||
10
ui/src/i18n/locales/es.ts
generated
10
ui/src/i18n/locales/es.ts
generated
|
|
@ -1566,6 +1566,16 @@ export const es: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Desconectado de la puerta de enlace.",
|
||||
archivedSessionDisabled: "Restaura esta sesión para enviar mensajes.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Tarea de seguimiento sugerida",
|
||||
start: "Iniciar en un worktree",
|
||||
starting: "Iniciando…",
|
||||
dismiss: "Descartar {title}",
|
||||
project: "Proyecto",
|
||||
instructions: "Instrucciones",
|
||||
adminRequired:
|
||||
"Se requiere acceso de administrador para crear un worktree para este proyecto.",
|
||||
},
|
||||
refreshTitle: "Actualizar datos del chat",
|
||||
settings: "Configuración del chat",
|
||||
usageRemaining: "Uso restante",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/fa.ts
generated
9
ui/src/i18n/locales/fa.ts
generated
|
|
@ -1557,6 +1557,15 @@ export const fa: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "اتصال از Gateway قطع شد.",
|
||||
archivedSessionDisabled: "برای ارسال پیام، این نشست را بازیابی کنید.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "کار پیگیری پیشنهادی",
|
||||
start: "شروع در درخت کاری",
|
||||
starting: "در حال شروع…",
|
||||
dismiss: "رد کردن {title}",
|
||||
project: "پروژه",
|
||||
instructions: "دستورالعملها",
|
||||
adminRequired: "برای ایجاد درخت کاری از این پروژه، دسترسی مدیر لازم است.",
|
||||
},
|
||||
refreshTitle: "تازهسازی دادههای چت",
|
||||
settings: "تنظیمات چت",
|
||||
usageRemaining: "استفاده باقیمانده",
|
||||
|
|
|
|||
10
ui/src/i18n/locales/fr.ts
generated
10
ui/src/i18n/locales/fr.ts
generated
|
|
@ -1576,6 +1576,16 @@ export const fr: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Déconnecté du Gateway.",
|
||||
archivedSessionDisabled: "Restaurez cette session pour envoyer des messages.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Suivi suggéré",
|
||||
start: "Démarrer dans un worktree",
|
||||
starting: "Démarrage…",
|
||||
dismiss: "Ignorer {title}",
|
||||
project: "Projet",
|
||||
instructions: "Instructions",
|
||||
adminRequired:
|
||||
"Un accès administrateur est requis pour créer un worktree à partir de ce projet.",
|
||||
},
|
||||
refreshTitle: "Actualiser les données du chat",
|
||||
settings: "Paramètres de chat",
|
||||
usageRemaining: "Utilisation restante",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/hi.ts
generated
9
ui/src/i18n/locales/hi.ts
generated
|
|
@ -1541,6 +1541,15 @@ export const hi: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Gateway से डिस्कनेक्ट हो गया।",
|
||||
archivedSessionDisabled: "संदेश भेजने के लिए इस सत्र को बहाल करें।",
|
||||
taskSuggestions: {
|
||||
eyebrow: "सुझाया गया अगला कार्य",
|
||||
start: "वर्कट्री में शुरू करें",
|
||||
starting: "शुरू हो रहा है…",
|
||||
dismiss: "{title} को खारिज करें",
|
||||
project: "प्रोजेक्ट",
|
||||
instructions: "निर्देश",
|
||||
adminRequired: "इस प्रोजेक्ट से वर्कट्री बनाने के लिए एडमिनिस्ट्रेटर एक्सेस आवश्यक है।",
|
||||
},
|
||||
refreshTitle: "चैट डेटा रीफ़्रेश करें",
|
||||
settings: "चैट सेटिंग्स",
|
||||
usageRemaining: "शेष उपयोग",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/id.ts
generated
9
ui/src/i18n/locales/id.ts
generated
|
|
@ -1558,6 +1558,15 @@ export const id: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Terputus dari gateway.",
|
||||
archivedSessionDisabled: "Pulihkan sesi ini untuk mengirim pesan.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Tindak lanjut yang disarankan",
|
||||
start: "Mulai di worktree",
|
||||
starting: "Memulai…",
|
||||
dismiss: "Tutup {title}",
|
||||
project: "Proyek",
|
||||
instructions: "Petunjuk",
|
||||
adminRequired: "Akses administrator diperlukan untuk membuat worktree dari proyek ini.",
|
||||
},
|
||||
refreshTitle: "Refresh data chat",
|
||||
settings: "Pengaturan chat",
|
||||
usageRemaining: "Sisa penggunaan",
|
||||
|
|
|
|||
10
ui/src/i18n/locales/it.ts
generated
10
ui/src/i18n/locales/it.ts
generated
|
|
@ -1568,6 +1568,16 @@ export const it: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Disconnesso dal gateway.",
|
||||
archivedSessionDisabled: "Ripristina questa sessione per inviare messaggi.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Attività successiva suggerita",
|
||||
start: "Avvia in un worktree",
|
||||
starting: "Avvio…",
|
||||
dismiss: "Ignora {title}",
|
||||
project: "Progetto",
|
||||
instructions: "Istruzioni",
|
||||
adminRequired:
|
||||
"È necessario l'accesso amministratore per creare un worktree da questo progetto.",
|
||||
},
|
||||
refreshTitle: "Aggiorna dati chat",
|
||||
settings: "Impostazioni chat",
|
||||
usageRemaining: "Utilizzo rimanente",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/ja-JP.ts
generated
9
ui/src/i18n/locales/ja-JP.ts
generated
|
|
@ -1564,6 +1564,15 @@ export const ja_JP: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Gateway から切断されました。",
|
||||
archivedSessionDisabled: "メッセージを送信するには、このセッションを復元してください。",
|
||||
taskSuggestions: {
|
||||
eyebrow: "提案されたフォローアップ",
|
||||
start: "ワークツリーで開始",
|
||||
starting: "開始中…",
|
||||
dismiss: "{title} を閉じる",
|
||||
project: "プロジェクト",
|
||||
instructions: "指示",
|
||||
adminRequired: "このプロジェクトからワークツリーを作成するには管理者権限が必要です。",
|
||||
},
|
||||
refreshTitle: "チャットデータを更新",
|
||||
settings: "チャット設定",
|
||||
usageRemaining: "残りの使用量",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/ko.ts
generated
9
ui/src/i18n/locales/ko.ts
generated
|
|
@ -1546,6 +1546,15 @@ export const ko: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Gateway와 연결이 끊어졌습니다.",
|
||||
archivedSessionDisabled: "메시지를 보내려면 이 세션을 복원하세요.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "제안된 후속 작업",
|
||||
start: "워크트리에서 시작",
|
||||
starting: "시작 중…",
|
||||
dismiss: "{title} 닫기",
|
||||
project: "프로젝트",
|
||||
instructions: "지침",
|
||||
adminRequired: "이 프로젝트에서 워크트리를 만들려면 관리자 권한이 필요합니다.",
|
||||
},
|
||||
refreshTitle: "채팅 데이터 새로고침",
|
||||
settings: "채팅 설정",
|
||||
usageRemaining: "남은 사용량",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/nl.ts
generated
9
ui/src/i18n/locales/nl.ts
generated
|
|
@ -1563,6 +1563,15 @@ export const nl: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Verbinding met Gateway verbroken.",
|
||||
archivedSessionDisabled: "Herstel deze sessie om berichten te verzenden.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Voorgestelde vervolgtaak",
|
||||
start: "Start in een worktree",
|
||||
starting: "Starten…",
|
||||
dismiss: "{title} negeren",
|
||||
project: "Project",
|
||||
instructions: "Instructies",
|
||||
adminRequired: "Beheerderstoegang is vereist om vanuit dit project een worktree te maken.",
|
||||
},
|
||||
refreshTitle: "Chatgegevens vernieuwen",
|
||||
settings: "Chatinstellingen",
|
||||
usageRemaining: "Resterend gebruik",
|
||||
|
|
|
|||
10
ui/src/i18n/locales/pl.ts
generated
10
ui/src/i18n/locales/pl.ts
generated
|
|
@ -1561,6 +1561,16 @@ export const pl: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Rozłączono z Gateway.",
|
||||
archivedSessionDisabled: "Przywróć tę sesję, aby wysyłać wiadomości.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Sugerowane zadanie uzupełniające",
|
||||
start: "Uruchom w drzewie roboczym",
|
||||
starting: "Uruchamianie…",
|
||||
dismiss: "Odrzuć {title}",
|
||||
project: "Projekt",
|
||||
instructions: "Instrukcje",
|
||||
adminRequired:
|
||||
"Dostęp administratora jest wymagany, aby utworzyć drzewo robocze z tego projektu.",
|
||||
},
|
||||
refreshTitle: "Odśwież dane czatu",
|
||||
settings: "Ustawienia czatu",
|
||||
usageRemaining: "Pozostałe użycie",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/pt-BR.ts
generated
9
ui/src/i18n/locales/pt-BR.ts
generated
|
|
@ -1559,6 +1559,15 @@ export const pt_BR: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Desconectado do gateway.",
|
||||
archivedSessionDisabled: "Restaure esta sessão para enviar mensagens.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Acompanhamento sugerido",
|
||||
start: "Iniciar em uma worktree",
|
||||
starting: "Iniciando…",
|
||||
dismiss: "Dispensar {title}",
|
||||
project: "Projeto",
|
||||
instructions: "Instruções",
|
||||
adminRequired: "É necessário acesso de administrador para criar uma worktree deste projeto.",
|
||||
},
|
||||
refreshTitle: "Atualizar dados do chat",
|
||||
settings: "Configurações do chat",
|
||||
usageRemaining: "Uso restante",
|
||||
|
|
|
|||
10
ui/src/i18n/locales/ru.ts
generated
10
ui/src/i18n/locales/ru.ts
generated
|
|
@ -1570,6 +1570,16 @@ export const ru: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Отключено от gateway.",
|
||||
archivedSessionDisabled: "Восстановите этот сеанс, чтобы отправлять сообщения.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Предлагаемая следующая задача",
|
||||
start: "Начать в рабочем дереве",
|
||||
starting: "Запуск…",
|
||||
dismiss: "Отклонить {title}",
|
||||
project: "Проект",
|
||||
instructions: "Инструкции",
|
||||
adminRequired:
|
||||
"Для создания рабочего дерева из этого проекта необходим доступ администратора.",
|
||||
},
|
||||
refreshTitle: "Обновить данные чата",
|
||||
settings: "Настройки чата",
|
||||
usageRemaining: "Оставшееся использование",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/th.ts
generated
9
ui/src/i18n/locales/th.ts
generated
|
|
@ -1523,6 +1523,15 @@ export const th: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "ตัดการเชื่อมต่อจากเกตเวย์แล้ว",
|
||||
archivedSessionDisabled: "กู้คืนเซสชันนี้เพื่อส่งข้อความ",
|
||||
taskSuggestions: {
|
||||
eyebrow: "งานติดตามผลที่แนะนำ",
|
||||
start: "เริ่มในเวิร์กทรี",
|
||||
starting: "กำลังเริ่ม…",
|
||||
dismiss: "ปิด {title}",
|
||||
project: "โปรเจกต์",
|
||||
instructions: "คำแนะนำ",
|
||||
adminRequired: "ต้องมีสิทธิ์ผู้ดูแลระบบเพื่อสร้างเวิร์กทรีจากโปรเจกต์นี้",
|
||||
},
|
||||
refreshTitle: "รีเฟรชข้อมูลแชต",
|
||||
settings: "การตั้งค่าแชท",
|
||||
usageRemaining: "การใช้งานคงเหลือ",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/tr.ts
generated
9
ui/src/i18n/locales/tr.ts
generated
|
|
@ -1563,6 +1563,15 @@ export const tr: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Gateway bağlantısı kesildi.",
|
||||
archivedSessionDisabled: "Mesaj göndermek için bu oturumu geri yükleyin.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Önerilen takip görevi",
|
||||
start: "Çalışma ağacında başlat",
|
||||
starting: "Başlatılıyor…",
|
||||
dismiss: "{title} önerisini kapat",
|
||||
project: "Proje",
|
||||
instructions: "Talimatlar",
|
||||
adminRequired: "Bu projeden bir çalışma ağacı oluşturmak için yönetici erişimi gerekir.",
|
||||
},
|
||||
refreshTitle: "Sohbet verilerini yenile",
|
||||
settings: "Sohbet ayarları",
|
||||
usageRemaining: "Kalan kullanım",
|
||||
|
|
|
|||
10
ui/src/i18n/locales/uk.ts
generated
10
ui/src/i18n/locales/uk.ts
generated
|
|
@ -1559,6 +1559,16 @@ export const uk: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Відключено від шлюзу.",
|
||||
archivedSessionDisabled: "Відновіть цей сеанс, щоб надсилати повідомлення.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Запропоноване подальше завдання",
|
||||
start: "Почати в робочому дереві",
|
||||
starting: "Запуск…",
|
||||
dismiss: "Відхилити {title}",
|
||||
project: "Проєкт",
|
||||
instructions: "Інструкції",
|
||||
adminRequired:
|
||||
"Для створення робочого дерева з цього проєкту потрібен доступ адміністратора.",
|
||||
},
|
||||
refreshTitle: "Оновити дані чату",
|
||||
settings: "Налаштування чату",
|
||||
usageRemaining: "Залишок використання",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/vi.ts
generated
9
ui/src/i18n/locales/vi.ts
generated
|
|
@ -1548,6 +1548,15 @@ export const vi: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "Đã ngắt kết nối khỏi gateway.",
|
||||
archivedSessionDisabled: "Khôi phục phiên này để gửi tin nhắn.",
|
||||
taskSuggestions: {
|
||||
eyebrow: "Tác vụ tiếp theo được đề xuất",
|
||||
start: "Bắt đầu trong worktree",
|
||||
starting: "Đang bắt đầu…",
|
||||
dismiss: "Bỏ qua {title}",
|
||||
project: "Dự án",
|
||||
instructions: "Hướng dẫn",
|
||||
adminRequired: "Cần quyền quản trị viên để tạo worktree từ dự án này.",
|
||||
},
|
||||
refreshTitle: "Làm mới dữ liệu trò chuyện",
|
||||
settings: "Cài đặt trò chuyện",
|
||||
usageRemaining: "Mức sử dụng còn lại",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/zh-CN.ts
generated
9
ui/src/i18n/locales/zh-CN.ts
generated
|
|
@ -1518,6 +1518,15 @@ export const zh_CN: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "已断开与网关的连接。",
|
||||
archivedSessionDisabled: "恢复此会话以发送消息。",
|
||||
taskSuggestions: {
|
||||
eyebrow: "建议的后续任务",
|
||||
start: "在工作树中开始",
|
||||
starting: "正在启动…",
|
||||
dismiss: "忽略 {title}",
|
||||
project: "项目",
|
||||
instructions: "说明",
|
||||
adminRequired: "需要管理员权限才能为此项目创建工作树。",
|
||||
},
|
||||
refreshTitle: "刷新聊天数据",
|
||||
settings: "聊天设置",
|
||||
usageRemaining: "剩余用量",
|
||||
|
|
|
|||
9
ui/src/i18n/locales/zh-TW.ts
generated
9
ui/src/i18n/locales/zh-TW.ts
generated
|
|
@ -1520,6 +1520,15 @@ export const zh_TW: TranslationMap = {
|
|||
chat: {
|
||||
disconnected: "已斷開與網關的連接。",
|
||||
archivedSessionDisabled: "還原此工作階段以傳送訊息。",
|
||||
taskSuggestions: {
|
||||
eyebrow: "建議的後續任務",
|
||||
start: "在工作樹中開始",
|
||||
starting: "正在啟動…",
|
||||
dismiss: "略過 {title}",
|
||||
project: "專案",
|
||||
instructions: "指示",
|
||||
adminRequired: "需要管理員權限才能為此專案建立工作樹。",
|
||||
},
|
||||
refreshTitle: "刷新聊天數據",
|
||||
settings: "聊天設定",
|
||||
usageRemaining: "剩餘用量",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export type SessionCreateParams = {
|
|||
label?: string;
|
||||
model?: string;
|
||||
worktree?: boolean;
|
||||
cwd?: string;
|
||||
task?: string;
|
||||
};
|
||||
|
||||
export function resolveSessionCreateParams(sessionKey = "", agentId?: string) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { consume } from "@lit/context";
|
||||
import { html, LitElement } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import type {
|
||||
TaskSuggestion,
|
||||
TaskSuggestionEvent,
|
||||
TaskSuggestionsAcceptResult,
|
||||
TaskSuggestionsListResult,
|
||||
} from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import {
|
||||
|
|
@ -8,7 +14,7 @@ import {
|
|||
type ApplicationContext,
|
||||
type ApplicationGatewaySnapshot,
|
||||
} from "../../app/context.ts";
|
||||
import { hasOperatorAdminAccess } from "../../app/operator-access.ts";
|
||||
import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
|
||||
import {
|
||||
COMMAND_PALETTE_TARGET_EVENT,
|
||||
type CommandPaletteTargetDetail,
|
||||
|
|
@ -137,6 +143,135 @@ class ChatPane extends LitElement {
|
|||
private connectionGeneration = 0;
|
||||
private nativeDraftCleanup: (() => void) | null = null;
|
||||
private readonly unreadPatchGuard = new SessionUnreadPatchGuard();
|
||||
private taskSuggestions: TaskSuggestion[] = [];
|
||||
private readonly taskSuggestionBusyIds = new Set<string>();
|
||||
private taskSuggestionsRequestVersion = 0;
|
||||
|
||||
private taskSuggestionMatchesCurrentSession(suggestion: TaskSuggestion): boolean {
|
||||
const state = this.state;
|
||||
return Boolean(
|
||||
state &&
|
||||
uiSessionEventMatches(
|
||||
{
|
||||
agentsList: this.context.agents.state.agentsList,
|
||||
hello: this.context.gateway.snapshot.hello,
|
||||
sessionKey: state.sessionKey,
|
||||
},
|
||||
suggestion.sessionKey,
|
||||
suggestion.agentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshTaskSuggestions(): Promise<void> {
|
||||
const state = this.state;
|
||||
const client = state?.client;
|
||||
const requestVersion = ++this.taskSuggestionsRequestVersion;
|
||||
if (
|
||||
!state?.connected ||
|
||||
!client ||
|
||||
!isGatewayMethodAdvertised(this.context.gateway.snapshot, "taskSuggestions.list")
|
||||
) {
|
||||
this.taskSuggestions = [];
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
const sessionKey = state.sessionKey;
|
||||
const agentId = resolveChatAgentId(state);
|
||||
try {
|
||||
const result = await client.request<TaskSuggestionsListResult>("taskSuggestions.list", {
|
||||
agentId,
|
||||
});
|
||||
if (
|
||||
requestVersion !== this.taskSuggestionsRequestVersion ||
|
||||
client !== this.state?.client ||
|
||||
sessionKey !== this.state?.sessionKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.taskSuggestions = result.suggestions.filter((suggestion) =>
|
||||
this.taskSuggestionMatchesCurrentSession(suggestion),
|
||||
);
|
||||
this.requestUpdate();
|
||||
} catch {
|
||||
// Suggestions are an optional ephemeral affordance; chat remains usable
|
||||
// when an older Gateway or a reconnect loses the process-local registry.
|
||||
// Keep event-delivered cards when a background reconciliation fails.
|
||||
}
|
||||
}
|
||||
|
||||
private handleTaskSuggestionEvent(event: TaskSuggestionEvent): void {
|
||||
if (event.action === "created") {
|
||||
if (!this.taskSuggestionMatchesCurrentSession(event.suggestion)) {
|
||||
return;
|
||||
}
|
||||
this.taskSuggestions = [
|
||||
event.suggestion,
|
||||
...this.taskSuggestions.filter((item) => item.id !== event.suggestion.id),
|
||||
];
|
||||
} else {
|
||||
this.taskSuggestions = this.taskSuggestions.filter((item) => item.id !== event.taskId);
|
||||
this.taskSuggestionBusyIds.delete(event.taskId);
|
||||
}
|
||||
this.requestUpdate();
|
||||
// The replacement snapshot includes the event plus unrelated suggestions;
|
||||
// its request version prevents any older snapshot from overwriting either.
|
||||
void this.refreshTaskSuggestions();
|
||||
}
|
||||
|
||||
private readonly acceptTaskSuggestion = async (suggestion: TaskSuggestion): Promise<void> => {
|
||||
const state = this.state;
|
||||
const client = state?.client;
|
||||
if (
|
||||
!state ||
|
||||
!client ||
|
||||
!this.taskSuggestionMatchesCurrentSession(suggestion) ||
|
||||
this.taskSuggestionBusyIds.has(suggestion.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const sessionKey = state.sessionKey;
|
||||
this.taskSuggestionBusyIds.add(suggestion.id);
|
||||
this.requestUpdate();
|
||||
try {
|
||||
const result = await client.request<TaskSuggestionsAcceptResult>("taskSuggestions.accept", {
|
||||
taskId: suggestion.id,
|
||||
});
|
||||
this.taskSuggestions = this.taskSuggestions.filter((item) => item.id !== suggestion.id);
|
||||
if (this.state?.sessionKey === sessionKey) {
|
||||
this.onPaneSessionChange?.(this.paneId, result.key);
|
||||
}
|
||||
} catch (error) {
|
||||
state.lastError = error instanceof Error ? error.message : String(error);
|
||||
state.chatError = state.lastError;
|
||||
} finally {
|
||||
this.taskSuggestionBusyIds.delete(suggestion.id);
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly dismissTaskSuggestion = async (suggestion: TaskSuggestion): Promise<void> => {
|
||||
const state = this.state;
|
||||
if (
|
||||
!state?.client ||
|
||||
!this.taskSuggestionMatchesCurrentSession(suggestion) ||
|
||||
this.taskSuggestionBusyIds.has(suggestion.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.taskSuggestionBusyIds.add(suggestion.id);
|
||||
this.requestUpdate();
|
||||
try {
|
||||
await state.client.request("taskSuggestions.dismiss", { taskId: suggestion.id });
|
||||
this.taskSuggestions = this.taskSuggestions.filter((item) => item.id !== suggestion.id);
|
||||
} catch (error) {
|
||||
state.lastError = error instanceof Error ? error.message : String(error);
|
||||
state.chatError = state.lastError;
|
||||
} finally {
|
||||
this.taskSuggestionBusyIds.delete(suggestion.id);
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
private markSessionRead(row: GatewaySessionRow | undefined) {
|
||||
const state = this.state;
|
||||
|
|
@ -196,6 +331,8 @@ class ChatPane extends LitElement {
|
|||
const nextSessionRow = state.sessionsResult?.sessions.find((row) => row.key === nextSessionKey);
|
||||
const nextSessionLabel = resolveSessionDisplayName(nextSessionKey, nextSessionRow);
|
||||
resetChatStateForRouteSession(state, nextSessionKey);
|
||||
this.taskSuggestionsRequestVersion += 1;
|
||||
this.taskSuggestions = [];
|
||||
this.markSessionRead(nextSessionRow);
|
||||
if (previousSessionKey !== nextSessionKey) {
|
||||
state.announceSessionSwitch?.(nextSessionKey, nextSessionLabel);
|
||||
|
|
@ -206,6 +343,7 @@ class ChatPane extends LitElement {
|
|||
const subscriptionSync = syncSelectedSessionMessageSubscription(state);
|
||||
const historyLoad = loadChatHistory(state);
|
||||
state.requestUpdate();
|
||||
void this.refreshTaskSuggestions();
|
||||
const scheduleHistoryScroll = () => {
|
||||
if (state.sessionKey !== nextSessionKey) {
|
||||
return;
|
||||
|
|
@ -481,6 +619,9 @@ class ChatPane extends LitElement {
|
|||
this.context.gateway.subscribeEvents((event) => {
|
||||
const state = this.state;
|
||||
if (state) {
|
||||
if (event.event === "task.suggestion" && event.payload) {
|
||||
this.handleTaskSuggestionEvent(event.payload as TaskSuggestionEvent);
|
||||
}
|
||||
handlePageGatewayEvent(state, event);
|
||||
}
|
||||
}),
|
||||
|
|
@ -715,6 +856,7 @@ class ChatPane extends LitElement {
|
|||
});
|
||||
void refreshChatModelAuthStatus(state).finally(() => state.requestUpdate?.());
|
||||
void state.loadAssistantIdentity();
|
||||
void this.refreshTaskSuggestions();
|
||||
}
|
||||
state.requestUpdate?.();
|
||||
}
|
||||
|
|
@ -929,6 +1071,16 @@ class ChatPane extends LitElement {
|
|||
onOpenSplitView: this.onOpenSplitView,
|
||||
}),
|
||||
sessionWorkspace: createSessionWorkspaceProps(state),
|
||||
taskSuggestions: this.taskSuggestions,
|
||||
taskSuggestionBusyIds: this.taskSuggestionBusyIds,
|
||||
canAcceptTaskSuggestions:
|
||||
state.connected &&
|
||||
hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null),
|
||||
canDismissTaskSuggestions:
|
||||
state.connected &&
|
||||
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null),
|
||||
onAcceptTaskSuggestion: (suggestion) => void this.acceptTaskSuggestion(suggestion),
|
||||
onDismissTaskSuggestion: (suggestion) => void this.dismissTaskSuggestion(suggestion),
|
||||
onOpenWorkspaceFile: (target) => openSessionWorkspaceFile(state, target),
|
||||
onRevealWorkspaceFile: (path) => revealSessionWorkspaceFile(state, path),
|
||||
onRefresh: () => {
|
||||
|
|
|
|||
65
ui/src/pages/chat/chat-task-suggestions.test.ts
Normal file
65
ui/src/pages/chat/chat-task-suggestions.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TaskSuggestion } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import { renderChatTaskSuggestions } from "./components/chat-task-suggestions.ts";
|
||||
|
||||
const suggestion: TaskSuggestion = {
|
||||
id: "task_123",
|
||||
title: "Remove stale adapter",
|
||||
prompt: "Delete the stale adapter and update tests.",
|
||||
tldr: "The adapter is unreachable and adds maintenance cost.",
|
||||
cwd: "/repo",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
describe("chat task suggestions", () => {
|
||||
it("renders an actionable chip", () => {
|
||||
const container = document.createElement("div");
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
renderChatTaskSuggestions({
|
||||
suggestions: [suggestion],
|
||||
busyIds: new Set(),
|
||||
canAccept: true,
|
||||
canDismiss: true,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Remove stale adapter");
|
||||
expect(container.textContent).toContain("The adapter is unreachable");
|
||||
expect(container.textContent).toContain("/repo");
|
||||
expect(container.textContent).toContain("Delete the stale adapter and update tests.");
|
||||
container.querySelector<HTMLButtonElement>(".task-suggestion__start")?.click();
|
||||
container.querySelector<HTMLButtonElement>(".task-suggestion__dismiss")?.click();
|
||||
expect(onAccept).toHaveBeenCalledWith(suggestion);
|
||||
expect(onDismiss).toHaveBeenCalledWith(suggestion);
|
||||
});
|
||||
|
||||
it("hides dismissal without write access and requires admin access to start", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChatTaskSuggestions({
|
||||
suggestions: [suggestion],
|
||||
busyIds: new Set(),
|
||||
canAccept: false,
|
||||
canDismiss: false,
|
||||
onAccept: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector<HTMLButtonElement>(".task-suggestion__start")?.disabled).toBe(
|
||||
true,
|
||||
);
|
||||
expect(container.querySelector(".task-suggestion__dismiss")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
import type { TaskSuggestion } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { SessionsListResult } from "../../api/types.ts";
|
||||
import type { ChatSendShortcut } from "../../app/settings.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
|
|
@ -24,12 +25,13 @@ import {
|
|||
renderSessionWorkspaceRail,
|
||||
type SessionWorkspaceProps,
|
||||
} from "./components/chat-session-workspace.ts";
|
||||
import "./components/chat-sidebar.ts";
|
||||
import type {
|
||||
DetailFullMessageResult,
|
||||
SidebarContent,
|
||||
SidebarFullMessageRequest,
|
||||
} from "./components/chat-sidebar.ts";
|
||||
import "./components/chat-sidebar.ts";
|
||||
import { renderChatTaskSuggestions } from "./components/chat-task-suggestions.ts";
|
||||
import {
|
||||
isChatThreadSearchOpen,
|
||||
renderChatPinnedMessages,
|
||||
|
|
@ -143,6 +145,12 @@ export type ChatProps = {
|
|||
onClearReply?: () => void;
|
||||
onSetReply?: (target: { messageId: string; text: string; senderLabel?: string | null }) => void;
|
||||
sessionWorkspace?: SessionWorkspaceProps;
|
||||
taskSuggestions?: TaskSuggestion[];
|
||||
taskSuggestionBusyIds?: ReadonlySet<string>;
|
||||
canAcceptTaskSuggestions?: boolean;
|
||||
canDismissTaskSuggestions?: boolean;
|
||||
onAcceptTaskSuggestion?: (suggestion: TaskSuggestion) => void;
|
||||
onDismissTaskSuggestion?: (suggestion: TaskSuggestion) => void;
|
||||
};
|
||||
|
||||
export function resetChatViewState(paneId?: string) {
|
||||
|
|
@ -343,7 +351,16 @@ export function renderChat(props: ChatProps) {
|
|||
class="chat-main"
|
||||
style="flex: ${sidebarOpen ? `0 1 ${splitRatio * 100}%` : "1 1 100%"}"
|
||||
>
|
||||
${thread} ${chatColumnFooter}
|
||||
${thread}
|
||||
${renderChatTaskSuggestions({
|
||||
suggestions: props.taskSuggestions ?? [],
|
||||
busyIds: props.taskSuggestionBusyIds ?? new Set(),
|
||||
canAccept: props.canAcceptTaskSuggestions === true,
|
||||
canDismiss: props.canDismissTaskSuggestions === true,
|
||||
onAccept: (suggestion) => props.onAcceptTaskSuggestion?.(suggestion),
|
||||
onDismiss: (suggestion) => props.onDismissTaskSuggestion?.(suggestion),
|
||||
})}
|
||||
${chatColumnFooter}
|
||||
</div>
|
||||
|
||||
${sidebarOpen
|
||||
|
|
|
|||
72
ui/src/pages/chat/components/chat-task-suggestions.ts
Normal file
72
ui/src/pages/chat/components/chat-task-suggestions.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Chat UI cards for model-proposed follow-up tasks.
|
||||
import { html, nothing } from "lit";
|
||||
import type { TaskSuggestion } from "../../../../../packages/gateway-protocol/src/index.js";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
|
||||
export function renderChatTaskSuggestions(props: {
|
||||
suggestions: TaskSuggestion[];
|
||||
busyIds: ReadonlySet<string>;
|
||||
canAccept: boolean;
|
||||
canDismiss: boolean;
|
||||
onAccept: (suggestion: TaskSuggestion) => void;
|
||||
onDismiss: (suggestion: TaskSuggestion) => void;
|
||||
}) {
|
||||
if (props.suggestions.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="task-suggestions" aria-live="polite">
|
||||
${props.suggestions.map((suggestion) => {
|
||||
const busy = props.busyIds.has(suggestion.id);
|
||||
return html`
|
||||
<article class="task-suggestion" data-task-id=${suggestion.id}>
|
||||
<div class="task-suggestion__icon" aria-hidden="true">${icons.spark}</div>
|
||||
<div class="task-suggestion__body">
|
||||
<div class="task-suggestion__eyebrow">${t("chat.taskSuggestions.eyebrow")}</div>
|
||||
<div class="task-suggestion__title">${suggestion.title}</div>
|
||||
<div class="task-suggestion__summary">${suggestion.tldr}</div>
|
||||
<div class="task-suggestion__details">
|
||||
<div class="task-suggestion__detail">
|
||||
<span>${t("chat.taskSuggestions.project")}</span>
|
||||
<code>${suggestion.cwd}</code>
|
||||
</div>
|
||||
<div class="task-suggestion__detail task-suggestion__detail--instructions">
|
||||
<span>${t("chat.taskSuggestions.instructions")}</span>
|
||||
<pre>${suggestion.prompt}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-suggestion__actions">
|
||||
<button
|
||||
class="btn btn--primary task-suggestion__start"
|
||||
type="button"
|
||||
?disabled=${busy || !props.canAccept}
|
||||
title=${props.canAccept ? "" : t("chat.taskSuggestions.adminRequired")}
|
||||
@click=${() => props.onAccept(suggestion)}
|
||||
>
|
||||
${icons.play}
|
||||
${busy ? t("chat.taskSuggestions.starting") : t("chat.taskSuggestions.start")}
|
||||
</button>
|
||||
${props.canDismiss
|
||||
? html`
|
||||
<button
|
||||
class="btn btn--ghost btn--icon task-suggestion__dismiss"
|
||||
type="button"
|
||||
?disabled=${busy}
|
||||
aria-label=${t("chat.taskSuggestions.dismiss", {
|
||||
title: suggestion.title,
|
||||
})}
|
||||
@click=${() => props.onDismiss(suggestion)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -834,6 +834,137 @@ openclaw-chat-page {
|
|||
margin: 8px auto 14px;
|
||||
}
|
||||
|
||||
.task-suggestions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: min(40vh, 24rem);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0 14px 10px;
|
||||
}
|
||||
|
||||
.task-suggestion {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border));
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
|
||||
}
|
||||
|
||||
.task-suggestion__icon {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
.task-suggestion__icon svg,
|
||||
.task-suggestion__start svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.task-suggestion__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-suggestion__eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-suggestion__title {
|
||||
margin-top: 2px;
|
||||
color: var(--text);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.task-suggestion__summary {
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.task-suggestion__details {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.task-suggestion__detail {
|
||||
display: grid;
|
||||
grid-template-columns: 78px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.task-suggestion__detail > span {
|
||||
padding-top: 3px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-suggestion__detail code,
|
||||
.task-suggestion__detail pre {
|
||||
min-width: 0;
|
||||
max-height: 9rem;
|
||||
margin: 0;
|
||||
padding: 5px 7px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--bg) 70%, transparent);
|
||||
color: var(--text);
|
||||
font: 11px/1.4 var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-suggestion__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-suggestion__start {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.task-suggestion {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-suggestion__actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
padding-left: 44px;
|
||||
}
|
||||
|
||||
.task-suggestion__detail {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-chat__input {
|
||||
--chat-box-inset: 8px;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue