netizen-cli 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2247 @@
|
|
|
1
|
+
"""Reply Card, Activity, and Files rendering with strict manifest decoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
from lark_channel import OutboundCard, new_card
|
|
13
|
+
|
|
14
|
+
from ..completion_mention import valid_completion_mention_user_id
|
|
15
|
+
from ..domain import (
|
|
16
|
+
ACTIVE_STATE_VALUES,
|
|
17
|
+
ActiveState,
|
|
18
|
+
CardControlName,
|
|
19
|
+
FeishuScope,
|
|
20
|
+
GoalOperationState,
|
|
21
|
+
GoalStatus,
|
|
22
|
+
ReplyCardActivityModule,
|
|
23
|
+
ReplyCardFileItem,
|
|
24
|
+
ReplyCardFilesModule,
|
|
25
|
+
ReplyCardGoalModule,
|
|
26
|
+
ReplyCardManifest,
|
|
27
|
+
ReplyCardProjection,
|
|
28
|
+
ReplyCardResultModule,
|
|
29
|
+
ScopeKind,
|
|
30
|
+
TurnActivityManifestEntry,
|
|
31
|
+
TurnCommentaryManifestEntry,
|
|
32
|
+
TurnFileActionIntent,
|
|
33
|
+
TurnFileActionName,
|
|
34
|
+
TurnFileManifestItem,
|
|
35
|
+
TurnProgressManifest,
|
|
36
|
+
TurnProgressManifestStep,
|
|
37
|
+
)
|
|
38
|
+
from ..turn_files import (
|
|
39
|
+
TURN_FILE_PAGE_SIZE,
|
|
40
|
+
TurnFile,
|
|
41
|
+
TurnFilePage,
|
|
42
|
+
inspect_turn_file_path,
|
|
43
|
+
paginate_turn_files,
|
|
44
|
+
)
|
|
45
|
+
from ..turn_activity import (
|
|
46
|
+
ACTIVITY_COMMENTARY_LIMIT,
|
|
47
|
+
ACTIVITY_DETAIL_KINDS,
|
|
48
|
+
ACTIVITY_OPERATION_LIMIT,
|
|
49
|
+
ACTIVITY_PLAN_LIMIT,
|
|
50
|
+
ACTIVITY_TEXT_LIMIT,
|
|
51
|
+
TurnActivityKind,
|
|
52
|
+
TurnActivityStatus,
|
|
53
|
+
normalize_activity_text_layout,
|
|
54
|
+
sanitize_activity_operation_text,
|
|
55
|
+
sanitize_activity_text,
|
|
56
|
+
)
|
|
57
|
+
from .callbacks import (
|
|
58
|
+
CardActionError,
|
|
59
|
+
REPLY_CARD_ACTION_VERSION,
|
|
60
|
+
TURN_FILE_ACTION_VERSION,
|
|
61
|
+
TurnFileCardLimitError,
|
|
62
|
+
_REPEATABLE_CARD_CONTROL_NAMES,
|
|
63
|
+
_binding_reference,
|
|
64
|
+
_button_row,
|
|
65
|
+
_callback_button,
|
|
66
|
+
_decode_binding_reference,
|
|
67
|
+
_decode_goal_generation,
|
|
68
|
+
_decode_turn_reference,
|
|
69
|
+
_envelope,
|
|
70
|
+
_md_code,
|
|
71
|
+
_notice,
|
|
72
|
+
_plain,
|
|
73
|
+
_plain_text,
|
|
74
|
+
_repeatable_callback_button,
|
|
75
|
+
_required_string,
|
|
76
|
+
_scope_from_envelope,
|
|
77
|
+
_turn_reference,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
TURN_FILE_MANIFEST_LIMIT = 400
|
|
82
|
+
TURN_FILE_CARD_JSON_LIMIT_BYTES = 55_000
|
|
83
|
+
_TURN_ANSWER_ELEMENT_ID = "turnanswerv1"
|
|
84
|
+
_TURN_FILES_ELEMENT_ID = "turnfilesv4"
|
|
85
|
+
_TURN_PROGRESS_ELEMENT_ID = "turnprogressv1"
|
|
86
|
+
_GOAL_ELEMENT_ID = "goalmodulev1"
|
|
87
|
+
_GOAL_OBJECTIVE_PREVIEW_CHARS = 200
|
|
88
|
+
_TURN_PROGRESS_MAX_STEPS = ACTIVITY_PLAN_LIMIT
|
|
89
|
+
_TURN_PROGRESS_STEP_MAX_CHARS = ACTIVITY_TEXT_LIMIT
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _TurnPlanStepLike(Protocol):
|
|
93
|
+
step: str
|
|
94
|
+
status: object
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _TurnActivityEntryLike(Protocol):
|
|
98
|
+
kind: object
|
|
99
|
+
status: object
|
|
100
|
+
event_timestamp_ms: int | None
|
|
101
|
+
text: str | None
|
|
102
|
+
count: int
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _TurnCommentaryEntryLike(Protocol):
|
|
106
|
+
event_timestamp_ms: int | None
|
|
107
|
+
text: str | None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _TurnActivitySnapshotLike(Protocol):
|
|
111
|
+
state: object
|
|
112
|
+
steer_count: int
|
|
113
|
+
plan_available: bool
|
|
114
|
+
plan_generated: bool
|
|
115
|
+
plan_may_be_stale: bool
|
|
116
|
+
steps: tuple[_TurnPlanStepLike, ...]
|
|
117
|
+
commentary: tuple[_TurnCommentaryEntryLike, ...]
|
|
118
|
+
operations: tuple[_TurnActivityEntryLike, ...]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def reply_card(projection: ReplyCardProjection) -> OutboundCard:
|
|
122
|
+
"""Render the closed Reply Card module set as one atomic Card 2.0 value.
|
|
123
|
+
|
|
124
|
+
The renderer is deterministic and does not read Runtime, SQLite, or the
|
|
125
|
+
filesystem. When Files is present every advertised page is rendered and
|
|
126
|
+
size-checked before the selected page is returned.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
normalized = _normalize_reply_projection(projection)
|
|
130
|
+
files_module = normalized.files
|
|
131
|
+
if files_module is None:
|
|
132
|
+
return _render_reply_card_page(normalized)
|
|
133
|
+
turn_files = _reply_turn_files(files_module.items)
|
|
134
|
+
requested = paginate_turn_files(turn_files, files_module.page)
|
|
135
|
+
selected: OutboundCard | None = None
|
|
136
|
+
for page in range(requested.total_pages):
|
|
137
|
+
candidate = _render_reply_card_page(
|
|
138
|
+
replace(
|
|
139
|
+
normalized,
|
|
140
|
+
files=replace(files_module, page=page),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
if page == requested.page:
|
|
144
|
+
selected = candidate
|
|
145
|
+
assert selected is not None
|
|
146
|
+
return selected
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def reply_card_from_manifest(
|
|
150
|
+
*,
|
|
151
|
+
scope: FeishuScope,
|
|
152
|
+
binding_id: str,
|
|
153
|
+
turn_id: str,
|
|
154
|
+
manifest: tuple[TurnFileManifestItem, ...],
|
|
155
|
+
reply: ReplyCardManifest,
|
|
156
|
+
page: int,
|
|
157
|
+
additions: int | None = None,
|
|
158
|
+
deletions: int | None = None,
|
|
159
|
+
) -> OutboundCard:
|
|
160
|
+
"""Rebuild a v5 Reply Card from one strict self-contained page callback."""
|
|
161
|
+
|
|
162
|
+
if not manifest:
|
|
163
|
+
raise CardActionError("本轮文件清单为空,请重新执行任务。")
|
|
164
|
+
if len(manifest) > TURN_FILE_MANIFEST_LIMIT:
|
|
165
|
+
raise TurnFileCardLimitError(
|
|
166
|
+
f"本轮文件共 {len(manifest)} 个,超过卡片完整分页上限 "
|
|
167
|
+
f"{TURN_FILE_MANIFEST_LIMIT} 个;未截断文件清单。"
|
|
168
|
+
)
|
|
169
|
+
inspected = tuple(_inspect_manifest_file(entry) for entry in manifest)
|
|
170
|
+
return reply_card(
|
|
171
|
+
ReplyCardProjection(
|
|
172
|
+
scope=scope,
|
|
173
|
+
goal=reply.goal,
|
|
174
|
+
activity=reply.activity,
|
|
175
|
+
result=reply.result,
|
|
176
|
+
files=ReplyCardFilesModule(
|
|
177
|
+
binding_id=binding_id,
|
|
178
|
+
turn_id=turn_id,
|
|
179
|
+
items=tuple(_reply_file_item(item) for item in inspected),
|
|
180
|
+
page=page,
|
|
181
|
+
action_version=REPLY_CARD_ACTION_VERSION,
|
|
182
|
+
additions=additions,
|
|
183
|
+
deletions=deletions,
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _normalize_reply_projection(
|
|
190
|
+
projection: ReplyCardProjection,
|
|
191
|
+
) -> ReplyCardProjection:
|
|
192
|
+
if not any(
|
|
193
|
+
(projection.goal, projection.activity, projection.result, projection.files)
|
|
194
|
+
):
|
|
195
|
+
raise ValueError("a Reply Card requires at least one module")
|
|
196
|
+
goal = _normalize_goal_module(projection.goal)
|
|
197
|
+
activity = _normalize_activity_module(projection.activity)
|
|
198
|
+
result = projection.result
|
|
199
|
+
if result is not None:
|
|
200
|
+
_bounded_card_text(result.content, "result", 100_000)
|
|
201
|
+
if (
|
|
202
|
+
result.completion_mention_user_id is not None
|
|
203
|
+
and valid_completion_mention_user_id(
|
|
204
|
+
result.completion_mention_user_id
|
|
205
|
+
) is None
|
|
206
|
+
):
|
|
207
|
+
raise ValueError("a completion mention requires one valid user open_id")
|
|
208
|
+
files = projection.files
|
|
209
|
+
if files is not None:
|
|
210
|
+
if projection.scope is None:
|
|
211
|
+
raise ValueError("a Files module requires scope")
|
|
212
|
+
if result is None:
|
|
213
|
+
raise ValueError("a Files module requires a Result module")
|
|
214
|
+
if not files.binding_id or not files.turn_id:
|
|
215
|
+
raise ValueError("a Files module requires binding_id and turn_id")
|
|
216
|
+
if not files.items:
|
|
217
|
+
raise CardActionError("本轮文件当前已不可用。")
|
|
218
|
+
if len(files.items) > TURN_FILE_MANIFEST_LIMIT:
|
|
219
|
+
raise TurnFileCardLimitError(
|
|
220
|
+
f"本轮文件共 {len(files.items)} 个,超过卡片完整分页上限 "
|
|
221
|
+
f"{TURN_FILE_MANIFEST_LIMIT} 个;未截断文件清单。"
|
|
222
|
+
)
|
|
223
|
+
if files.action_version not in {
|
|
224
|
+
TURN_FILE_ACTION_VERSION,
|
|
225
|
+
REPLY_CARD_ACTION_VERSION,
|
|
226
|
+
}:
|
|
227
|
+
raise ValueError("unsupported Reply Card file action version")
|
|
228
|
+
if (
|
|
229
|
+
files.action_version == TURN_FILE_ACTION_VERSION
|
|
230
|
+
and goal is not None
|
|
231
|
+
):
|
|
232
|
+
raise ValueError("a Goal + Files Reply Card requires v5 callbacks")
|
|
233
|
+
if goal is not None and goal.binding_id != files.binding_id:
|
|
234
|
+
raise ValueError("Goal and Files modules require the same binding_id")
|
|
235
|
+
_optional_line_counts(
|
|
236
|
+
files.additions,
|
|
237
|
+
files.deletions,
|
|
238
|
+
field="files",
|
|
239
|
+
)
|
|
240
|
+
normalized_files = _reply_turn_files(files.items)
|
|
241
|
+
files = replace(
|
|
242
|
+
files,
|
|
243
|
+
items=tuple(_reply_file_item(item) for item in normalized_files),
|
|
244
|
+
)
|
|
245
|
+
if goal is not None and projection.scope is None:
|
|
246
|
+
raise ValueError("a Goal module requires scope")
|
|
247
|
+
if activity is not None:
|
|
248
|
+
if activity.terminal_status is None:
|
|
249
|
+
if activity.collapsed:
|
|
250
|
+
raise ValueError("a running progress card must remain expanded")
|
|
251
|
+
if result is not None or files is not None:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
"a running Activity module cannot contain Result or Files"
|
|
254
|
+
)
|
|
255
|
+
elif files is not None and activity.terminal_status != "completed":
|
|
256
|
+
raise ValueError("only completed Activity may contain Files")
|
|
257
|
+
return replace(
|
|
258
|
+
projection,
|
|
259
|
+
goal=goal,
|
|
260
|
+
activity=activity,
|
|
261
|
+
files=files,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _normalize_goal_module(
|
|
266
|
+
goal: ReplyCardGoalModule | None,
|
|
267
|
+
) -> ReplyCardGoalModule | None:
|
|
268
|
+
if goal is None:
|
|
269
|
+
return None
|
|
270
|
+
_bounded_card_text(goal.binding_id, "goal.binding_id", 128)
|
|
271
|
+
_bounded_card_text(goal.short_id, "goal.short_id", 32)
|
|
272
|
+
_bounded_card_text(goal.project_alias, "goal.project_alias", 128)
|
|
273
|
+
if goal.status is None:
|
|
274
|
+
if any(
|
|
275
|
+
value is not None
|
|
276
|
+
for value in (
|
|
277
|
+
goal.goal_generation,
|
|
278
|
+
goal.runtime_state,
|
|
279
|
+
goal.objective,
|
|
280
|
+
goal.token_budget,
|
|
281
|
+
)
|
|
282
|
+
) or goal.tokens_used != 0:
|
|
283
|
+
raise ValueError("an empty Goal module cannot carry Goal state")
|
|
284
|
+
else:
|
|
285
|
+
if goal.status not in {item.value for item in GoalStatus}:
|
|
286
|
+
raise ValueError("unsupported Goal status")
|
|
287
|
+
_decode_goal_generation(goal.goal_generation)
|
|
288
|
+
assert goal.objective is not None
|
|
289
|
+
_bounded_card_text(goal.objective, "goal.objective", 10_000)
|
|
290
|
+
if goal.runtime_state is not None:
|
|
291
|
+
_bounded_card_text(goal.runtime_state, "goal.runtime_state", 128)
|
|
292
|
+
_bounded_nonnegative_int(goal.tokens_used, "goal.tokens_used")
|
|
293
|
+
if goal.token_budget is not None:
|
|
294
|
+
_bounded_nonnegative_int(goal.token_budget, "goal.token_budget")
|
|
295
|
+
if goal.notice is not None:
|
|
296
|
+
_bounded_card_text(goal.notice, "goal.notice", 4_000)
|
|
297
|
+
if type(goal.notice_is_error) is not bool:
|
|
298
|
+
raise ValueError("goal.notice_is_error must be a boolean")
|
|
299
|
+
return goal
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _normalize_activity_module(
|
|
303
|
+
activity: ReplyCardActivityModule | None,
|
|
304
|
+
) -> ReplyCardActivityModule | None:
|
|
305
|
+
if activity is None:
|
|
306
|
+
return None
|
|
307
|
+
terminal_status = _terminal_progress_status(activity.terminal_status)
|
|
308
|
+
progress = _sanitize_turn_progress_manifest(activity.progress)
|
|
309
|
+
_bounded_nonnegative_int(activity.hidden_steps, "activity.hidden_steps")
|
|
310
|
+
return replace(
|
|
311
|
+
activity,
|
|
312
|
+
progress=progress,
|
|
313
|
+
terminal_status=terminal_status,
|
|
314
|
+
collapsed=activity.collapsed or terminal_status is not None,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _sanitize_turn_progress_manifest(
|
|
319
|
+
progress: TurnProgressManifest,
|
|
320
|
+
) -> TurnProgressManifest:
|
|
321
|
+
return _decode_turn_progress_manifest(_encode_turn_progress_manifest(progress))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _activity_timestamp_ms(value: object) -> int | None:
|
|
325
|
+
if value is None:
|
|
326
|
+
return None
|
|
327
|
+
if (
|
|
328
|
+
isinstance(value, bool)
|
|
329
|
+
or not isinstance(value, int)
|
|
330
|
+
or not 0 <= value <= 10**18
|
|
331
|
+
):
|
|
332
|
+
raise ValueError("activity timestamp is invalid")
|
|
333
|
+
return value
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _decode_activity_timestamp_ms(value: object, field: str) -> int | None:
|
|
337
|
+
try:
|
|
338
|
+
return _activity_timestamp_ms(value)
|
|
339
|
+
except ValueError as error:
|
|
340
|
+
raise CardActionError(f"{field} 无效。") from error
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _activity_operation_text(kind: object, value: object) -> str | None:
|
|
344
|
+
if value is None:
|
|
345
|
+
return None
|
|
346
|
+
if not isinstance(value, str) or not value:
|
|
347
|
+
raise ValueError("activity operation text is invalid")
|
|
348
|
+
if kind in ACTIVITY_DETAIL_KINDS:
|
|
349
|
+
# Preserve frozen summaries from older 160-character manifests on
|
|
350
|
+
# pagination, including exit-code suffixes. New native previews use 120.
|
|
351
|
+
if len(value) > ACTIVITY_TEXT_LIMIT:
|
|
352
|
+
raise ValueError("activity operation text must be bounded")
|
|
353
|
+
return sanitize_activity_operation_text(value, limit=ACTIVITY_TEXT_LIMIT)
|
|
354
|
+
if kind == TurnActivityKind.TOOL.value:
|
|
355
|
+
return value
|
|
356
|
+
raise ValueError("activity text is unsupported for this operation")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _bounded_card_text(value: Any, field: str, limit: int) -> str:
|
|
360
|
+
text = _required_string(value, field)
|
|
361
|
+
if len(text) > limit or "\x00" in text:
|
|
362
|
+
raise ValueError(f"{field} is invalid")
|
|
363
|
+
return text
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _bounded_nonnegative_int(value: Any, field: str) -> int:
|
|
367
|
+
if (
|
|
368
|
+
isinstance(value, bool)
|
|
369
|
+
or not isinstance(value, int)
|
|
370
|
+
or not 0 <= value <= 10**18
|
|
371
|
+
):
|
|
372
|
+
raise ValueError(f"{field} must be a bounded non-negative integer")
|
|
373
|
+
return value
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _optional_line_counts(
|
|
377
|
+
additions: Any,
|
|
378
|
+
deletions: Any,
|
|
379
|
+
*,
|
|
380
|
+
field: str,
|
|
381
|
+
) -> tuple[int | None, int | None]:
|
|
382
|
+
if additions is None and deletions is None:
|
|
383
|
+
return None, None
|
|
384
|
+
if additions is None or deletions is None:
|
|
385
|
+
raise ValueError(f"{field} line counts must be present together")
|
|
386
|
+
return (
|
|
387
|
+
_bounded_nonnegative_int(additions, f"{field}.additions"),
|
|
388
|
+
_bounded_nonnegative_int(deletions, f"{field}.deletions"),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _inspect_manifest_file(entry: TurnFileManifestItem) -> TurnFile:
|
|
393
|
+
if entry.deleted:
|
|
394
|
+
return TurnFile(
|
|
395
|
+
entry.label, Path(entry.path), None, None,
|
|
396
|
+
entry.additions, entry.deletions, deleted=True,
|
|
397
|
+
)
|
|
398
|
+
inspected = inspect_turn_file_path(entry.path, entry.label)
|
|
399
|
+
if not inspected.available or inspected.media_kind == "image":
|
|
400
|
+
return inspected
|
|
401
|
+
return replace(
|
|
402
|
+
inspected,
|
|
403
|
+
additions=entry.additions,
|
|
404
|
+
deletions=entry.deletions,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _reply_file_item(turn_file: TurnFile) -> ReplyCardFileItem:
|
|
409
|
+
return ReplyCardFileItem(
|
|
410
|
+
path=str(turn_file.resolved_path),
|
|
411
|
+
label=turn_file.display_path,
|
|
412
|
+
size=turn_file.size,
|
|
413
|
+
media_kind=turn_file.media_kind,
|
|
414
|
+
additions=turn_file.additions,
|
|
415
|
+
deletions=turn_file.deletions,
|
|
416
|
+
deleted=turn_file.deleted,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _reply_turn_files(
|
|
421
|
+
items: tuple[ReplyCardFileItem, ...],
|
|
422
|
+
) -> tuple[TurnFile, ...]:
|
|
423
|
+
results: list[TurnFile] = []
|
|
424
|
+
seen: set[str] = set()
|
|
425
|
+
for item in items:
|
|
426
|
+
path = _decode_turn_file_path(item.path)
|
|
427
|
+
label = _bounded_card_text(item.label, "file.label", 1024)
|
|
428
|
+
if path in seen:
|
|
429
|
+
raise CardActionError("本轮文件清单包含重复路径。")
|
|
430
|
+
seen.add(path)
|
|
431
|
+
size = item.size
|
|
432
|
+
media_kind = item.media_kind
|
|
433
|
+
if type(item.deleted) is not bool or (item.deleted and size is not None):
|
|
434
|
+
raise ValueError("deleted files must be unavailable")
|
|
435
|
+
if (size is None) != (media_kind is None):
|
|
436
|
+
raise ValueError("file availability fields must change together")
|
|
437
|
+
if size is not None:
|
|
438
|
+
_bounded_nonnegative_int(size, "file.size")
|
|
439
|
+
if media_kind not in {"image", "file"}:
|
|
440
|
+
raise ValueError("file.media_kind is invalid")
|
|
441
|
+
additions, deletions = _optional_line_counts(
|
|
442
|
+
item.additions,
|
|
443
|
+
item.deletions,
|
|
444
|
+
field="file",
|
|
445
|
+
)
|
|
446
|
+
if media_kind != "file" and not item.deleted:
|
|
447
|
+
additions = None
|
|
448
|
+
deletions = None
|
|
449
|
+
results.append(
|
|
450
|
+
TurnFile(
|
|
451
|
+
display_path=label,
|
|
452
|
+
resolved_path=Path(path),
|
|
453
|
+
size=size,
|
|
454
|
+
media_kind=media_kind,
|
|
455
|
+
additions=additions,
|
|
456
|
+
deletions=deletions,
|
|
457
|
+
deleted=item.deleted,
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
return tuple(results)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def turn_files_card(
|
|
464
|
+
*,
|
|
465
|
+
scope: FeishuScope,
|
|
466
|
+
binding_id: str,
|
|
467
|
+
turn_id: str,
|
|
468
|
+
final_response: str,
|
|
469
|
+
files: tuple[TurnFile, ...],
|
|
470
|
+
page: int = 0,
|
|
471
|
+
additions: int | None = None,
|
|
472
|
+
deletions: int | None = None,
|
|
473
|
+
completion_mention_user_id: str | None = None,
|
|
474
|
+
) -> OutboundCard:
|
|
475
|
+
return reply_card(
|
|
476
|
+
ReplyCardProjection(
|
|
477
|
+
scope=scope,
|
|
478
|
+
result=ReplyCardResultModule(
|
|
479
|
+
final_response,
|
|
480
|
+
completion_mention_user_id=completion_mention_user_id,
|
|
481
|
+
),
|
|
482
|
+
files=ReplyCardFilesModule(
|
|
483
|
+
binding_id=binding_id,
|
|
484
|
+
turn_id=turn_id,
|
|
485
|
+
items=tuple(_reply_file_item(item) for item in files),
|
|
486
|
+
page=page,
|
|
487
|
+
action_version=TURN_FILE_ACTION_VERSION,
|
|
488
|
+
additions=additions,
|
|
489
|
+
deletions=deletions,
|
|
490
|
+
),
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def turn_progress_card(
|
|
496
|
+
*,
|
|
497
|
+
snapshot: _TurnActivitySnapshotLike,
|
|
498
|
+
final_response: str | None = None,
|
|
499
|
+
files: tuple[TurnFile, ...] = (),
|
|
500
|
+
terminal_status: str | None = None,
|
|
501
|
+
collapsed: bool = False,
|
|
502
|
+
scope: FeishuScope | None = None,
|
|
503
|
+
binding_id: str | None = None,
|
|
504
|
+
turn_id: str | None = None,
|
|
505
|
+
additions: int | None = None,
|
|
506
|
+
deletions: int | None = None,
|
|
507
|
+
completion_mention_user_id: str | None = None,
|
|
508
|
+
) -> OutboundCard:
|
|
509
|
+
"""Render one replaceable Phase 1 Turn progress card.
|
|
510
|
+
|
|
511
|
+
The activity panel reads only the bounded, safety-projected status,
|
|
512
|
+
commentary, generic operations, and checklist. It never inspects
|
|
513
|
+
reasoning, tool arguments, or tool output.
|
|
514
|
+
A terminal render always collapses that panel and appends the authoritative
|
|
515
|
+
final response plus the existing optional Turn-file controls.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
normalized_terminal_status = _terminal_progress_status(terminal_status)
|
|
519
|
+
if normalized_terminal_status is None:
|
|
520
|
+
if completion_mention_user_id is not None:
|
|
521
|
+
raise ValueError("a running progress card cannot contain a completion mention")
|
|
522
|
+
if collapsed:
|
|
523
|
+
raise ValueError("a running progress card must remain expanded")
|
|
524
|
+
if final_response is not None or files:
|
|
525
|
+
raise ValueError(
|
|
526
|
+
"a running progress card cannot contain a final response or files"
|
|
527
|
+
)
|
|
528
|
+
elif files and normalized_terminal_status != "completed":
|
|
529
|
+
raise ValueError("only a completed progress card may contain files")
|
|
530
|
+
|
|
531
|
+
activity = ReplyCardActivityModule(
|
|
532
|
+
progress=_turn_progress_manifest(snapshot),
|
|
533
|
+
terminal_status=normalized_terminal_status,
|
|
534
|
+
collapsed=collapsed,
|
|
535
|
+
# v4 file callbacks freeze only the already-bounded progress manifest.
|
|
536
|
+
# Preserve their established initial/paged behavior until that legacy
|
|
537
|
+
# schema ages out; v5 Goal cards carry hidden_steps explicitly.
|
|
538
|
+
hidden_steps=(
|
|
539
|
+
0
|
|
540
|
+
if files
|
|
541
|
+
else max(0, len(snapshot.steps) - _TURN_PROGRESS_MAX_STEPS)
|
|
542
|
+
),
|
|
543
|
+
)
|
|
544
|
+
files_module = None
|
|
545
|
+
if files:
|
|
546
|
+
if scope is None or not binding_id or not turn_id:
|
|
547
|
+
raise ValueError(
|
|
548
|
+
"a progress card with files requires scope, binding_id, and turn_id"
|
|
549
|
+
)
|
|
550
|
+
files_module = ReplyCardFilesModule(
|
|
551
|
+
binding_id=binding_id,
|
|
552
|
+
turn_id=turn_id,
|
|
553
|
+
items=tuple(_reply_file_item(item) for item in files),
|
|
554
|
+
action_version=TURN_FILE_ACTION_VERSION,
|
|
555
|
+
additions=additions,
|
|
556
|
+
deletions=deletions,
|
|
557
|
+
)
|
|
558
|
+
result = None
|
|
559
|
+
if normalized_terminal_status is not None:
|
|
560
|
+
result = ReplyCardResultModule(
|
|
561
|
+
final_response or _default_terminal_response(normalized_terminal_status),
|
|
562
|
+
completion_mention_user_id=completion_mention_user_id,
|
|
563
|
+
)
|
|
564
|
+
return reply_card(
|
|
565
|
+
ReplyCardProjection(
|
|
566
|
+
scope=scope,
|
|
567
|
+
activity=activity,
|
|
568
|
+
result=result,
|
|
569
|
+
files=files_module,
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _turn_file_manifest(
|
|
575
|
+
files: tuple[TurnFile, ...],
|
|
576
|
+
) -> tuple[TurnFileManifestItem, ...]:
|
|
577
|
+
return tuple(
|
|
578
|
+
TurnFileManifestItem(
|
|
579
|
+
path=str(turn_file.resolved_path),
|
|
580
|
+
label=turn_file.display_path,
|
|
581
|
+
additions=turn_file.additions,
|
|
582
|
+
deletions=turn_file.deletions,
|
|
583
|
+
deleted=turn_file.deleted,
|
|
584
|
+
)
|
|
585
|
+
for turn_file in files
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _turn_progress_manifest(
|
|
590
|
+
snapshot: _TurnActivitySnapshotLike,
|
|
591
|
+
) -> TurnProgressManifest:
|
|
592
|
+
state = getattr(snapshot.state, "value", snapshot.state)
|
|
593
|
+
if state not in ACTIVE_STATE_VALUES:
|
|
594
|
+
state = ActiveState.RUNNING.value
|
|
595
|
+
steps = tuple(
|
|
596
|
+
TurnProgressManifestStep(
|
|
597
|
+
step=activity_step_display(item.step),
|
|
598
|
+
status=(
|
|
599
|
+
getattr(item.status, "value", item.status)
|
|
600
|
+
if getattr(item.status, "value", item.status)
|
|
601
|
+
in {"pending", "inProgress", "completed"}
|
|
602
|
+
else "pending"
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
for item in snapshot.steps[:_TURN_PROGRESS_MAX_STEPS]
|
|
606
|
+
)
|
|
607
|
+
snapshot_commentary = tuple(getattr(snapshot, "commentary", ()))
|
|
608
|
+
snapshot_operations = tuple(getattr(snapshot, "operations", ()))
|
|
609
|
+
commentary = tuple(
|
|
610
|
+
TurnCommentaryManifestEntry(
|
|
611
|
+
text=sanitized,
|
|
612
|
+
event_timestamp_ms=_activity_timestamp_ms(item.event_timestamp_ms),
|
|
613
|
+
)
|
|
614
|
+
for item in snapshot_commentary[-ACTIVITY_COMMENTARY_LIMIT:]
|
|
615
|
+
if item.text is not None
|
|
616
|
+
and (sanitized := sanitize_activity_text(item.text)) is not None
|
|
617
|
+
)
|
|
618
|
+
operations = tuple(
|
|
619
|
+
TurnActivityManifestEntry(
|
|
620
|
+
kind=(
|
|
621
|
+
getattr(item.kind, "value", item.kind)
|
|
622
|
+
if getattr(item.kind, "value", item.kind)
|
|
623
|
+
in {kind.value for kind in TurnActivityKind if kind is not TurnActivityKind.COMMENTARY}
|
|
624
|
+
else TurnActivityKind.TOOL.value
|
|
625
|
+
),
|
|
626
|
+
status=(
|
|
627
|
+
getattr(item.status, "value", item.status)
|
|
628
|
+
if getattr(item.status, "value", item.status)
|
|
629
|
+
in {status.value for status in TurnActivityStatus}
|
|
630
|
+
else TurnActivityStatus.IN_PROGRESS.value
|
|
631
|
+
),
|
|
632
|
+
event_timestamp_ms=_activity_timestamp_ms(item.event_timestamp_ms),
|
|
633
|
+
text=_activity_operation_text(
|
|
634
|
+
getattr(item.kind, "value", item.kind),
|
|
635
|
+
item.text,
|
|
636
|
+
),
|
|
637
|
+
count=max(0, item.count),
|
|
638
|
+
)
|
|
639
|
+
for item in snapshot_operations[-ACTIVITY_OPERATION_LIMIT:]
|
|
640
|
+
)
|
|
641
|
+
return TurnProgressManifest(
|
|
642
|
+
state=state,
|
|
643
|
+
steer_count=max(0, snapshot.steer_count),
|
|
644
|
+
plan_available=bool(snapshot.plan_available),
|
|
645
|
+
plan_generated=bool(snapshot.plan_generated),
|
|
646
|
+
plan_may_be_stale=bool(snapshot.plan_may_be_stale),
|
|
647
|
+
steps=steps,
|
|
648
|
+
commentary=commentary,
|
|
649
|
+
operations=operations,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _render_reply_card_page(projection: ReplyCardProjection) -> OutboundCard:
|
|
654
|
+
"""Pure single-page renderer; callers validate every page atomically."""
|
|
655
|
+
|
|
656
|
+
title, subtitle, template, summary = _reply_card_chrome(projection)
|
|
657
|
+
builder = (
|
|
658
|
+
new_card()
|
|
659
|
+
.config(
|
|
660
|
+
update_multi=True,
|
|
661
|
+
width_mode="default",
|
|
662
|
+
summary={"content": summary},
|
|
663
|
+
)
|
|
664
|
+
.header(
|
|
665
|
+
title,
|
|
666
|
+
subtitle=subtitle,
|
|
667
|
+
template=template,
|
|
668
|
+
icon={"tag": "standard_icon", "token": "todo_colorful"},
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
if projection.goal is not None:
|
|
672
|
+
builder.raw(
|
|
673
|
+
_reply_goal_block(
|
|
674
|
+
scope=projection.scope,
|
|
675
|
+
goal=projection.goal,
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
if projection.activity is not None:
|
|
679
|
+
activity = projection.activity
|
|
680
|
+
builder.raw(
|
|
681
|
+
_turn_progress_panel(
|
|
682
|
+
activity.progress,
|
|
683
|
+
terminal_status=activity.terminal_status,
|
|
684
|
+
expanded=not activity.collapsed,
|
|
685
|
+
hidden_steps=activity.hidden_steps,
|
|
686
|
+
goal=projection.goal is not None,
|
|
687
|
+
)
|
|
688
|
+
)
|
|
689
|
+
if projection.result is not None:
|
|
690
|
+
builder.raw(_turn_answer_block(projection.result))
|
|
691
|
+
if projection.files is not None:
|
|
692
|
+
assert projection.scope is not None
|
|
693
|
+
files = projection.files
|
|
694
|
+
turn_files = _reply_turn_files(files.items)
|
|
695
|
+
visible = paginate_turn_files(turn_files, files.page)
|
|
696
|
+
for block in _turn_files_blocks(
|
|
697
|
+
scope=projection.scope,
|
|
698
|
+
binding_id=files.binding_id,
|
|
699
|
+
turn_id=files.turn_id,
|
|
700
|
+
page=visible,
|
|
701
|
+
manifest=tuple(
|
|
702
|
+
TurnFileManifestItem(
|
|
703
|
+
item.path,
|
|
704
|
+
item.label,
|
|
705
|
+
item.additions,
|
|
706
|
+
item.deletions,
|
|
707
|
+
deleted=item.deleted,
|
|
708
|
+
)
|
|
709
|
+
for item in files.items
|
|
710
|
+
),
|
|
711
|
+
final_response=(
|
|
712
|
+
projection.result.content
|
|
713
|
+
if projection.result is not None
|
|
714
|
+
else ""
|
|
715
|
+
),
|
|
716
|
+
progress=(
|
|
717
|
+
projection.activity.progress
|
|
718
|
+
if projection.activity is not None
|
|
719
|
+
else None
|
|
720
|
+
),
|
|
721
|
+
reply=_reply_card_manifest(projection),
|
|
722
|
+
action_version=files.action_version,
|
|
723
|
+
additions=files.additions,
|
|
724
|
+
deletions=files.deletions,
|
|
725
|
+
):
|
|
726
|
+
builder.raw(block)
|
|
727
|
+
card = builder.to_dict()
|
|
728
|
+
body = card.get("body")
|
|
729
|
+
if isinstance(body, dict):
|
|
730
|
+
body.update(
|
|
731
|
+
{
|
|
732
|
+
"direction": "vertical",
|
|
733
|
+
"padding": "12px 12px 20px 12px",
|
|
734
|
+
"vertical_spacing": "12px",
|
|
735
|
+
}
|
|
736
|
+
)
|
|
737
|
+
_validate_turn_card_size(
|
|
738
|
+
card,
|
|
739
|
+
label="回复卡片",
|
|
740
|
+
untruncated="卡片内容",
|
|
741
|
+
)
|
|
742
|
+
return OutboundCard(card=card)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _reply_card_chrome(
|
|
746
|
+
projection: ReplyCardProjection,
|
|
747
|
+
) -> tuple[str, str | None, str, str]:
|
|
748
|
+
goal = projection.goal
|
|
749
|
+
if goal is not None:
|
|
750
|
+
state = goal.status
|
|
751
|
+
if state is None and goal.notice_is_error:
|
|
752
|
+
template = "red"
|
|
753
|
+
summary = "Goal 状态未确认"
|
|
754
|
+
elif state == GoalStatus.COMPLETE.value:
|
|
755
|
+
template = "green"
|
|
756
|
+
summary = "Goal 已完成"
|
|
757
|
+
elif state in {
|
|
758
|
+
GoalStatus.BLOCKED.value,
|
|
759
|
+
GoalStatus.USAGE_LIMITED.value,
|
|
760
|
+
GoalStatus.BUDGET_LIMITED.value,
|
|
761
|
+
}:
|
|
762
|
+
template = "orange"
|
|
763
|
+
summary = "Goal 等待处理"
|
|
764
|
+
elif state == GoalStatus.PAUSED.value:
|
|
765
|
+
template = "orange"
|
|
766
|
+
summary = "Goal 已暂停"
|
|
767
|
+
else:
|
|
768
|
+
template = "blue"
|
|
769
|
+
summary = "Goal 正在执行" if state else "Codex Goal"
|
|
770
|
+
return (
|
|
771
|
+
"Codex Goal",
|
|
772
|
+
f"{goal.short_id} · {goal.project_alias}",
|
|
773
|
+
template,
|
|
774
|
+
summary,
|
|
775
|
+
)
|
|
776
|
+
if projection.activity is not None:
|
|
777
|
+
title, template, summary = _progress_card_chrome(
|
|
778
|
+
projection.activity.terminal_status
|
|
779
|
+
)
|
|
780
|
+
return title, None, template, summary
|
|
781
|
+
if projection.files is not None:
|
|
782
|
+
files = projection.files
|
|
783
|
+
visible = paginate_turn_files(_reply_turn_files(files.items), files.page)
|
|
784
|
+
subtitle = (
|
|
785
|
+
f"本轮文件 {visible.total_items} 个 · "
|
|
786
|
+
f"第 {visible.page + 1}/{visible.total_pages} 页"
|
|
787
|
+
)
|
|
788
|
+
return (
|
|
789
|
+
"任务已完成",
|
|
790
|
+
subtitle,
|
|
791
|
+
"green",
|
|
792
|
+
f"任务已完成 · 本轮文件 {visible.total_items} 个",
|
|
793
|
+
)
|
|
794
|
+
return "任务已完成", None, "green", "任务已完成"
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _reply_goal_block(
|
|
798
|
+
*,
|
|
799
|
+
scope: FeishuScope | None,
|
|
800
|
+
goal: ReplyCardGoalModule,
|
|
801
|
+
) -> dict[str, Any]:
|
|
802
|
+
elements: list[dict[str, Any]] = []
|
|
803
|
+
if goal.notice:
|
|
804
|
+
elements.append(_notice(goal.notice, error=goal.notice_is_error))
|
|
805
|
+
if goal.status is None:
|
|
806
|
+
if goal.notice_is_error:
|
|
807
|
+
elements.append(
|
|
808
|
+
{
|
|
809
|
+
"tag": "markdown",
|
|
810
|
+
"content": (
|
|
811
|
+
"无法安全确认原生 Goal 是否存在;当前会话仍保持占用,"
|
|
812
|
+
"请勿启动新的原生操作。"
|
|
813
|
+
),
|
|
814
|
+
}
|
|
815
|
+
)
|
|
816
|
+
else:
|
|
817
|
+
elements.append(
|
|
818
|
+
{
|
|
819
|
+
"tag": "markdown",
|
|
820
|
+
"content": (
|
|
821
|
+
"当前原生 Thread 没有 Goal。使用 `/goal <objective>` 启动;"
|
|
822
|
+
"Goal 可跨多个物理 Turn 自动继续。"
|
|
823
|
+
),
|
|
824
|
+
}
|
|
825
|
+
)
|
|
826
|
+
else:
|
|
827
|
+
state = goal.runtime_state or f"goal-{goal.status}"
|
|
828
|
+
budget = "未设置" if goal.token_budget is None else str(goal.token_budget)
|
|
829
|
+
objective = _goal_objective_preview(goal.objective or "")
|
|
830
|
+
elements.append(
|
|
831
|
+
{
|
|
832
|
+
"tag": "markdown",
|
|
833
|
+
"content": (
|
|
834
|
+
f"**状态**:`{_md_code(state)}`\n"
|
|
835
|
+
f"**Objective**:{_md_code(objective)}\n"
|
|
836
|
+
f"**Tokens**:{goal.tokens_used} / {budget}"
|
|
837
|
+
),
|
|
838
|
+
}
|
|
839
|
+
)
|
|
840
|
+
assert scope is not None and goal.goal_generation is not None
|
|
841
|
+
buttons: list[dict[str, Any]] = []
|
|
842
|
+
external = goal.runtime_state == "externally-active-goal"
|
|
843
|
+
controls_unknown = goal.runtime_state == "goal-unknown"
|
|
844
|
+
pausing = goal.runtime_state == GoalOperationState.PAUSING.value
|
|
845
|
+
if (
|
|
846
|
+
goal.status == GoalStatus.ACTIVE.value
|
|
847
|
+
and not external
|
|
848
|
+
and not controls_unknown
|
|
849
|
+
and not pausing
|
|
850
|
+
):
|
|
851
|
+
buttons.append(
|
|
852
|
+
_goal_control_button(
|
|
853
|
+
scope=scope,
|
|
854
|
+
goal=goal,
|
|
855
|
+
name=CardControlName.GOAL_PAUSE,
|
|
856
|
+
label="暂停 Goal",
|
|
857
|
+
style="primary",
|
|
858
|
+
confirm=("暂停 Goal", "将暂停 Goal 并中断当前物理 Turn。"),
|
|
859
|
+
)
|
|
860
|
+
)
|
|
861
|
+
if (
|
|
862
|
+
goal.status == GoalStatus.PAUSED.value
|
|
863
|
+
and not external
|
|
864
|
+
and not controls_unknown
|
|
865
|
+
):
|
|
866
|
+
buttons.append(
|
|
867
|
+
_goal_control_button(
|
|
868
|
+
scope=scope,
|
|
869
|
+
goal=goal,
|
|
870
|
+
name=CardControlName.GOAL_RESUME,
|
|
871
|
+
label="恢复 Goal",
|
|
872
|
+
style="primary_filled",
|
|
873
|
+
)
|
|
874
|
+
)
|
|
875
|
+
if (
|
|
876
|
+
goal.status != GoalStatus.ACTIVE.value
|
|
877
|
+
and not controls_unknown
|
|
878
|
+
and goal.runtime_state != "goal-cleared"
|
|
879
|
+
):
|
|
880
|
+
buttons.append(
|
|
881
|
+
_goal_control_button(
|
|
882
|
+
scope=scope,
|
|
883
|
+
goal=goal,
|
|
884
|
+
name=CardControlName.GOAL_CLEAR,
|
|
885
|
+
label="结束 Goal",
|
|
886
|
+
confirm=("结束 Goal", "结束后将无法从此 Goal 状态恢复。"),
|
|
887
|
+
)
|
|
888
|
+
)
|
|
889
|
+
if buttons:
|
|
890
|
+
elements.append(_button_row(*buttons))
|
|
891
|
+
elif external:
|
|
892
|
+
elements.append(
|
|
893
|
+
_notice(
|
|
894
|
+
"这是重启前或外部客户端启动的 active Goal;"
|
|
895
|
+
"当前 SDK 无法安全补收通知并重挂。请先在原生 Codex 中暂停。"
|
|
896
|
+
)
|
|
897
|
+
)
|
|
898
|
+
return {
|
|
899
|
+
"tag": "column_set",
|
|
900
|
+
"element_id": _GOAL_ELEMENT_ID,
|
|
901
|
+
"flex_mode": "none",
|
|
902
|
+
"background_style": "grey-50",
|
|
903
|
+
"columns": [
|
|
904
|
+
{
|
|
905
|
+
"tag": "column",
|
|
906
|
+
"width": "weighted",
|
|
907
|
+
"weight": 1,
|
|
908
|
+
"padding": "12px",
|
|
909
|
+
"vertical_spacing": "8px",
|
|
910
|
+
"elements": elements,
|
|
911
|
+
}
|
|
912
|
+
],
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _goal_objective_preview(objective: str) -> str:
|
|
917
|
+
if len(objective) <= _GOAL_OBJECTIVE_PREVIEW_CHARS:
|
|
918
|
+
return objective
|
|
919
|
+
return f"{objective[:_GOAL_OBJECTIVE_PREVIEW_CHARS]}…"
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _goal_control_button(
|
|
923
|
+
*,
|
|
924
|
+
scope: FeishuScope,
|
|
925
|
+
goal: ReplyCardGoalModule,
|
|
926
|
+
name: CardControlName,
|
|
927
|
+
label: str,
|
|
928
|
+
style: str | None = None,
|
|
929
|
+
confirm: tuple[str, str] | None = None,
|
|
930
|
+
) -> dict[str, Any]:
|
|
931
|
+
button = (
|
|
932
|
+
_repeatable_callback_button
|
|
933
|
+
if name in _REPEATABLE_CARD_CONTROL_NAMES
|
|
934
|
+
else _callback_button
|
|
935
|
+
)
|
|
936
|
+
return button(
|
|
937
|
+
label=label,
|
|
938
|
+
value=_envelope(
|
|
939
|
+
scope,
|
|
940
|
+
name,
|
|
941
|
+
binding_id=_binding_reference(goal.binding_id),
|
|
942
|
+
goal_generation=goal.goal_generation,
|
|
943
|
+
expected_goal_status=goal.status,
|
|
944
|
+
),
|
|
945
|
+
style=style or "default",
|
|
946
|
+
confirm=confirm,
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _reply_card_manifest(projection: ReplyCardProjection) -> ReplyCardManifest:
|
|
951
|
+
return ReplyCardManifest(
|
|
952
|
+
goal=projection.goal,
|
|
953
|
+
activity=projection.activity,
|
|
954
|
+
result=(
|
|
955
|
+
None
|
|
956
|
+
if projection.result is None
|
|
957
|
+
else replace(projection.result, completion_mention_user_id=None)
|
|
958
|
+
),
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _terminal_progress_status(value: str | None) -> str | None:
|
|
963
|
+
if value is None:
|
|
964
|
+
return None
|
|
965
|
+
normalized = getattr(value, "value", value)
|
|
966
|
+
if normalized not in {"completed", "interrupted", "failed"}:
|
|
967
|
+
raise ValueError(f"unsupported terminal progress status: {normalized!r}")
|
|
968
|
+
return normalized
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _progress_card_chrome(status: str | None) -> tuple[str, str, str]:
|
|
972
|
+
if status == "completed":
|
|
973
|
+
return "任务已完成", "green", "任务已完成"
|
|
974
|
+
if status == "interrupted":
|
|
975
|
+
return "任务已中断", "orange", "任务已中断"
|
|
976
|
+
if status == "failed":
|
|
977
|
+
return "任务未完成", "red", "任务未完成"
|
|
978
|
+
return "任务进行中", "blue", "任务正在执行,进度会逐步更新"
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _default_terminal_response(status: str) -> str:
|
|
982
|
+
if status == "completed":
|
|
983
|
+
return "任务已结束,未产生文本回复。"
|
|
984
|
+
if status == "interrupted":
|
|
985
|
+
return "Codex Turn 已中断。"
|
|
986
|
+
return "任务未完成。"
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _turn_progress_panel(
|
|
990
|
+
snapshot: _TurnActivitySnapshotLike,
|
|
991
|
+
*,
|
|
992
|
+
terminal_status: str | None,
|
|
993
|
+
expanded: bool,
|
|
994
|
+
hidden_steps: int = 0,
|
|
995
|
+
goal: bool = False,
|
|
996
|
+
) -> dict[str, Any]:
|
|
997
|
+
status_label = _progress_status_label(snapshot, terminal_status)
|
|
998
|
+
return {
|
|
999
|
+
"tag": "collapsible_panel",
|
|
1000
|
+
"element_id": _TURN_PROGRESS_ELEMENT_ID,
|
|
1001
|
+
"expanded": expanded,
|
|
1002
|
+
"border": {"color": "grey", "corner_radius": "8px"},
|
|
1003
|
+
"padding": "8px 12px 12px 12px",
|
|
1004
|
+
"vertical_spacing": "8px",
|
|
1005
|
+
"header": {
|
|
1006
|
+
"title": {
|
|
1007
|
+
"tag": "plain_text",
|
|
1008
|
+
"content": f"执行过程 · {status_label}",
|
|
1009
|
+
},
|
|
1010
|
+
"vertical_align": "center",
|
|
1011
|
+
"icon": {
|
|
1012
|
+
"tag": "standard_icon",
|
|
1013
|
+
"token": "down-small-ccm_outlined",
|
|
1014
|
+
"size": "16px 16px",
|
|
1015
|
+
},
|
|
1016
|
+
"icon_position": "right",
|
|
1017
|
+
"icon_expanded_angle": -180,
|
|
1018
|
+
},
|
|
1019
|
+
"elements": _turn_activity_elements(
|
|
1020
|
+
snapshot,
|
|
1021
|
+
status_label=status_label,
|
|
1022
|
+
hidden_steps=hidden_steps,
|
|
1023
|
+
goal=goal,
|
|
1024
|
+
),
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _progress_status_label(
|
|
1029
|
+
snapshot: _TurnActivitySnapshotLike,
|
|
1030
|
+
terminal_status: str | None,
|
|
1031
|
+
) -> str:
|
|
1032
|
+
if terminal_status == "completed":
|
|
1033
|
+
return "已完成"
|
|
1034
|
+
if terminal_status == "interrupted":
|
|
1035
|
+
return "已中断"
|
|
1036
|
+
if terminal_status == "failed":
|
|
1037
|
+
return "未完成"
|
|
1038
|
+
state = getattr(snapshot.state, "value", snapshot.state)
|
|
1039
|
+
if state == ActiveState.STOPPING.value:
|
|
1040
|
+
return "正在停止"
|
|
1041
|
+
if state == ActiveState.OBSERVATION_UNAVAILABLE.value:
|
|
1042
|
+
return "Turn 观测不可用"
|
|
1043
|
+
return "正在执行"
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _turn_activity_elements(
|
|
1047
|
+
snapshot: _TurnActivitySnapshotLike,
|
|
1048
|
+
*,
|
|
1049
|
+
status_label: str,
|
|
1050
|
+
hidden_steps: int = 0,
|
|
1051
|
+
goal: bool = False,
|
|
1052
|
+
) -> list[dict[str, Any]]:
|
|
1053
|
+
elements = [_plain(f"状态:{status_label}")]
|
|
1054
|
+
if snapshot.steer_count:
|
|
1055
|
+
elements.append(_plain(f"已接收调整:{snapshot.steer_count} 次"))
|
|
1056
|
+
commentary = tuple(getattr(snapshot, "commentary", ()))[
|
|
1057
|
+
-ACTIVITY_COMMENTARY_LIMIT:
|
|
1058
|
+
]
|
|
1059
|
+
if commentary:
|
|
1060
|
+
elements.append({"tag": "markdown", "content": "**最近进展**"})
|
|
1061
|
+
for item in commentary:
|
|
1062
|
+
if item.text is None:
|
|
1063
|
+
continue
|
|
1064
|
+
elements.append(
|
|
1065
|
+
_activity_markdown_row(
|
|
1066
|
+
item.event_timestamp_ms,
|
|
1067
|
+
activity_step_display(item.text),
|
|
1068
|
+
)
|
|
1069
|
+
)
|
|
1070
|
+
operations = tuple(getattr(snapshot, "operations", ()))[
|
|
1071
|
+
-ACTIVITY_OPERATION_LIMIT:
|
|
1072
|
+
]
|
|
1073
|
+
if operations:
|
|
1074
|
+
elements.append({"tag": "markdown", "content": "**最近操作**"})
|
|
1075
|
+
for item in operations:
|
|
1076
|
+
elements.append(
|
|
1077
|
+
_activity_markdown_row(
|
|
1078
|
+
item.event_timestamp_ms,
|
|
1079
|
+
_activity_operation_display(item),
|
|
1080
|
+
)
|
|
1081
|
+
)
|
|
1082
|
+
if not snapshot.plan_available:
|
|
1083
|
+
elements.append(_plain("过程信息:暂不可用"))
|
|
1084
|
+
return elements
|
|
1085
|
+
if not snapshot.plan_generated:
|
|
1086
|
+
suffix = (
|
|
1087
|
+
"(最近一次调整后仍在等待更新)"
|
|
1088
|
+
if snapshot.plan_may_be_stale
|
|
1089
|
+
else ""
|
|
1090
|
+
)
|
|
1091
|
+
elements.append(
|
|
1092
|
+
{
|
|
1093
|
+
"tag": "markdown",
|
|
1094
|
+
"content": f"**任务清单**:Codex 尚未生成{suffix}",
|
|
1095
|
+
}
|
|
1096
|
+
)
|
|
1097
|
+
return elements
|
|
1098
|
+
|
|
1099
|
+
title = "**任务清单**"
|
|
1100
|
+
if goal:
|
|
1101
|
+
title += "(最近一次原生上报,可能尚未反映追加消息)"
|
|
1102
|
+
elif snapshot.plan_may_be_stale:
|
|
1103
|
+
title += "(可能尚未反映最近一次调整)"
|
|
1104
|
+
elements.append({"tag": "markdown", "content": title})
|
|
1105
|
+
visible = snapshot.steps[:_TURN_PROGRESS_MAX_STEPS]
|
|
1106
|
+
if not visible:
|
|
1107
|
+
elements.append(_plain("(当前为空)"))
|
|
1108
|
+
icons = {"completed": "✓", "inProgress": "→", "pending": "○"}
|
|
1109
|
+
for item in visible:
|
|
1110
|
+
status = getattr(item.status, "value", item.status)
|
|
1111
|
+
elements.append(
|
|
1112
|
+
_plain(
|
|
1113
|
+
f"{icons.get(status, '○')} "
|
|
1114
|
+
f"{activity_step_display(item.step)}"
|
|
1115
|
+
)
|
|
1116
|
+
)
|
|
1117
|
+
remaining = len(snapshot.steps) - len(visible) + hidden_steps
|
|
1118
|
+
if remaining > 0:
|
|
1119
|
+
elements.append(_plain(f"… 另有 {remaining} 项未展示"))
|
|
1120
|
+
return elements
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _activity_operation_display(item: _TurnActivityEntryLike) -> str:
|
|
1124
|
+
kind = getattr(item.kind, "value", item.kind)
|
|
1125
|
+
status = getattr(item.status, "value", item.status)
|
|
1126
|
+
labels = {
|
|
1127
|
+
TurnActivityKind.COMMAND.value: "执行命令",
|
|
1128
|
+
TurnActivityKind.TOOL.value: "调用工具",
|
|
1129
|
+
TurnActivityKind.FILE_CHANGE.value: "修改文件",
|
|
1130
|
+
TurnActivityKind.WEB_SEARCH.value: "搜索网页",
|
|
1131
|
+
TurnActivityKind.IMAGE.value: "处理图片",
|
|
1132
|
+
TurnActivityKind.SUBAGENT.value: "子任务",
|
|
1133
|
+
TurnActivityKind.REVIEW.value: "代码审查",
|
|
1134
|
+
TurnActivityKind.COMPACTION.value: "压缩上下文",
|
|
1135
|
+
}
|
|
1136
|
+
icons = {
|
|
1137
|
+
TurnActivityStatus.IN_PROGRESS.value: "→",
|
|
1138
|
+
TurnActivityStatus.COMPLETED.value: "✓",
|
|
1139
|
+
TurnActivityStatus.FAILED.value: "×",
|
|
1140
|
+
TurnActivityStatus.DECLINED.value: "×",
|
|
1141
|
+
TurnActivityStatus.INTERRUPTED.value: "×",
|
|
1142
|
+
}
|
|
1143
|
+
label = labels.get(kind, "执行操作")
|
|
1144
|
+
if kind in ACTIVITY_DETAIL_KINDS and item.text:
|
|
1145
|
+
label = item.text
|
|
1146
|
+
count = item.count
|
|
1147
|
+
if kind in {
|
|
1148
|
+
TurnActivityKind.FILE_CHANGE.value,
|
|
1149
|
+
TurnActivityKind.SUBAGENT.value,
|
|
1150
|
+
} and count != 1:
|
|
1151
|
+
label += f"({max(0, count)} 项)"
|
|
1152
|
+
if kind == TurnActivityKind.TOOL.value and item.text:
|
|
1153
|
+
label += f":{item.text}"
|
|
1154
|
+
return f"{icons.get(status, '→')} {label}"
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _activity_markdown_row(
|
|
1158
|
+
timestamp_ms: int | None,
|
|
1159
|
+
content: str,
|
|
1160
|
+
) -> dict[str, Any]:
|
|
1161
|
+
prefix = ""
|
|
1162
|
+
if timestamp_ms is not None:
|
|
1163
|
+
timestamp = _activity_timestamp_ms(timestamp_ms)
|
|
1164
|
+
assert timestamp is not None
|
|
1165
|
+
prefix = (
|
|
1166
|
+
f"<local_datetime millisecond='{timestamp}' "
|
|
1167
|
+
"format_type='date_num'></local_datetime> "
|
|
1168
|
+
f"<local_datetime millisecond='{timestamp}' "
|
|
1169
|
+
"format_type='time'></local_datetime> · "
|
|
1170
|
+
)
|
|
1171
|
+
return {
|
|
1172
|
+
"tag": "markdown",
|
|
1173
|
+
"content": prefix + _escape_activity_markdown(content),
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _escape_activity_markdown(value: str) -> str:
|
|
1178
|
+
visible = normalize_activity_text_layout(value)
|
|
1179
|
+
escaped = html.escape(visible.replace("\\", "\\\\"), quote=False)
|
|
1180
|
+
for marker in ("`", "*", "_", "~", "[", "]", "(", ")"):
|
|
1181
|
+
escaped = escaped.replace(marker, f"\\{marker}")
|
|
1182
|
+
return escaped
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def activity_step_display(value: str) -> str:
|
|
1186
|
+
"""Return bounded display text with conservative credential redaction."""
|
|
1187
|
+
|
|
1188
|
+
return sanitize_activity_text(value) or "未命名步骤"
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def turn_files_card_from_manifest(
|
|
1192
|
+
*,
|
|
1193
|
+
scope: FeishuScope,
|
|
1194
|
+
binding_id: str,
|
|
1195
|
+
turn_id: str,
|
|
1196
|
+
final_response: str,
|
|
1197
|
+
manifest: tuple[TurnFileManifestItem, ...],
|
|
1198
|
+
page: int,
|
|
1199
|
+
additions: int | None = None,
|
|
1200
|
+
deletions: int | None = None,
|
|
1201
|
+
) -> OutboundCard:
|
|
1202
|
+
"""Rebuild a v4 card using only state carried by its page callback."""
|
|
1203
|
+
|
|
1204
|
+
if not manifest:
|
|
1205
|
+
raise CardActionError("本轮文件清单为空,请重新执行任务。")
|
|
1206
|
+
if len(manifest) > TURN_FILE_MANIFEST_LIMIT:
|
|
1207
|
+
raise TurnFileCardLimitError(
|
|
1208
|
+
f"本轮文件共 {len(manifest)} 个,超过卡片完整分页上限 "
|
|
1209
|
+
f"{TURN_FILE_MANIFEST_LIMIT} 个;未截断文件清单。"
|
|
1210
|
+
)
|
|
1211
|
+
files = tuple(_inspect_manifest_file(entry) for entry in manifest)
|
|
1212
|
+
return reply_card(
|
|
1213
|
+
ReplyCardProjection(
|
|
1214
|
+
scope=scope,
|
|
1215
|
+
result=ReplyCardResultModule(final_response),
|
|
1216
|
+
files=ReplyCardFilesModule(
|
|
1217
|
+
binding_id=binding_id,
|
|
1218
|
+
turn_id=turn_id,
|
|
1219
|
+
items=tuple(_reply_file_item(item) for item in files),
|
|
1220
|
+
page=page,
|
|
1221
|
+
action_version=TURN_FILE_ACTION_VERSION,
|
|
1222
|
+
additions=additions,
|
|
1223
|
+
deletions=deletions,
|
|
1224
|
+
),
|
|
1225
|
+
)
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def turn_progress_card_from_manifest(
|
|
1230
|
+
*,
|
|
1231
|
+
scope: FeishuScope,
|
|
1232
|
+
binding_id: str,
|
|
1233
|
+
turn_id: str,
|
|
1234
|
+
final_response: str,
|
|
1235
|
+
manifest: tuple[TurnFileManifestItem, ...],
|
|
1236
|
+
progress: TurnProgressManifest,
|
|
1237
|
+
page: int,
|
|
1238
|
+
additions: int | None = None,
|
|
1239
|
+
deletions: int | None = None,
|
|
1240
|
+
) -> OutboundCard:
|
|
1241
|
+
"""Rebuild a completed progress card from its self-contained callback."""
|
|
1242
|
+
|
|
1243
|
+
if not manifest:
|
|
1244
|
+
raise CardActionError("本轮文件清单为空,请重新执行任务。")
|
|
1245
|
+
if len(manifest) > TURN_FILE_MANIFEST_LIMIT:
|
|
1246
|
+
raise TurnFileCardLimitError(
|
|
1247
|
+
f"本轮文件共 {len(manifest)} 个,超过卡片完整分页上限 "
|
|
1248
|
+
f"{TURN_FILE_MANIFEST_LIMIT} 个;未截断文件清单。"
|
|
1249
|
+
)
|
|
1250
|
+
files = tuple(_inspect_manifest_file(entry) for entry in manifest)
|
|
1251
|
+
return reply_card(
|
|
1252
|
+
ReplyCardProjection(
|
|
1253
|
+
scope=scope,
|
|
1254
|
+
activity=ReplyCardActivityModule(
|
|
1255
|
+
progress=progress,
|
|
1256
|
+
terminal_status="completed",
|
|
1257
|
+
collapsed=True,
|
|
1258
|
+
),
|
|
1259
|
+
result=ReplyCardResultModule(final_response),
|
|
1260
|
+
files=ReplyCardFilesModule(
|
|
1261
|
+
binding_id=binding_id,
|
|
1262
|
+
turn_id=turn_id,
|
|
1263
|
+
items=tuple(_reply_file_item(item) for item in files),
|
|
1264
|
+
page=page,
|
|
1265
|
+
action_version=TURN_FILE_ACTION_VERSION,
|
|
1266
|
+
additions=additions,
|
|
1267
|
+
deletions=deletions,
|
|
1268
|
+
),
|
|
1269
|
+
)
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def _validate_turn_card_size(
|
|
1274
|
+
card: Mapping[str, Any],
|
|
1275
|
+
*,
|
|
1276
|
+
label: str,
|
|
1277
|
+
untruncated: str,
|
|
1278
|
+
) -> None:
|
|
1279
|
+
# Match the Channel SDK's actual outbound Card serialization rather than
|
|
1280
|
+
# undercounting with compact separators.
|
|
1281
|
+
encoded_size = len(json.dumps(card, ensure_ascii=False).encode("utf-8"))
|
|
1282
|
+
if encoded_size > TURN_FILE_CARD_JSON_LIMIT_BYTES:
|
|
1283
|
+
raise TurnFileCardLimitError(
|
|
1284
|
+
f"{label}编码后为 {encoded_size} bytes,"
|
|
1285
|
+
"超过已验证的平台安全上限 "
|
|
1286
|
+
f"{TURN_FILE_CARD_JSON_LIMIT_BYTES} bytes;未截断{untruncated}。"
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def is_turn_file_action(value: Any) -> bool:
|
|
1291
|
+
if not isinstance(value, Mapping):
|
|
1292
|
+
return False
|
|
1293
|
+
return value.get("intent") in {item.value for item in TurnFileActionName}
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def decode_turn_file_action(
|
|
1297
|
+
*,
|
|
1298
|
+
app_id: str,
|
|
1299
|
+
message_id: str,
|
|
1300
|
+
callback_chat_id: str,
|
|
1301
|
+
sender_id: str,
|
|
1302
|
+
tag: str,
|
|
1303
|
+
value: Any,
|
|
1304
|
+
form_value: Any = None,
|
|
1305
|
+
) -> TurnFileActionIntent:
|
|
1306
|
+
if tag != "button":
|
|
1307
|
+
raise CardActionError(f"不支持的本轮文件组件:{tag or 'unknown'}")
|
|
1308
|
+
if not message_id or not callback_chat_id or not sender_id:
|
|
1309
|
+
raise CardActionError("本轮文件回调缺少消息、聊天或操作者标识。")
|
|
1310
|
+
if not isinstance(value, Mapping):
|
|
1311
|
+
raise CardActionError("本轮文件动作 value 必须是对象。")
|
|
1312
|
+
payload = dict(value)
|
|
1313
|
+
payload.pop("nonce", None)
|
|
1314
|
+
try:
|
|
1315
|
+
name = TurnFileActionName(payload.get("intent"))
|
|
1316
|
+
except (TypeError, ValueError) as error:
|
|
1317
|
+
raise CardActionError("未知本轮文件动作。") from error
|
|
1318
|
+
select_page = name is TurnFileActionName.PAGE and "pagination" in payload
|
|
1319
|
+
if select_page and payload.pop("pagination") != "select":
|
|
1320
|
+
raise CardActionError("本轮文件分页方式无效。")
|
|
1321
|
+
if not select_page and form_value is not None:
|
|
1322
|
+
raise CardActionError("本轮文件动作不接受表单字段。")
|
|
1323
|
+
version = payload.get("v")
|
|
1324
|
+
if (
|
|
1325
|
+
isinstance(version, bool)
|
|
1326
|
+
or not isinstance(version, int)
|
|
1327
|
+
or version
|
|
1328
|
+
not in {TURN_FILE_ACTION_VERSION, REPLY_CARD_ACTION_VERSION}
|
|
1329
|
+
):
|
|
1330
|
+
raise CardActionError("本轮文件卡片已过期,请重新执行任务。")
|
|
1331
|
+
try:
|
|
1332
|
+
scope_kind = ScopeKind(payload.get("scope_kind"))
|
|
1333
|
+
except (TypeError, ValueError) as error:
|
|
1334
|
+
raise CardActionError("未知 Scope kind。") from error
|
|
1335
|
+
common = {
|
|
1336
|
+
"v",
|
|
1337
|
+
"intent",
|
|
1338
|
+
"chat_id",
|
|
1339
|
+
"scope_kind",
|
|
1340
|
+
"binding_id",
|
|
1341
|
+
"turn_id",
|
|
1342
|
+
}
|
|
1343
|
+
scope_fields = {"topic_id"} if scope_kind is ScopeKind.TOPIC else set()
|
|
1344
|
+
action_fields = {"path"}
|
|
1345
|
+
allowed_action_fields = (action_fields,)
|
|
1346
|
+
if name is TurnFileActionName.PAGE:
|
|
1347
|
+
if version == TURN_FILE_ACTION_VERSION:
|
|
1348
|
+
action_fields = {"page", "files", "answer"}
|
|
1349
|
+
allowed_action_fields = (
|
|
1350
|
+
action_fields,
|
|
1351
|
+
action_fields | {"a", "d"},
|
|
1352
|
+
action_fields | {"progress"},
|
|
1353
|
+
action_fields | {"progress", "a", "d"},
|
|
1354
|
+
)
|
|
1355
|
+
else:
|
|
1356
|
+
action_fields = {"page", "files", "reply"}
|
|
1357
|
+
allowed_action_fields = (
|
|
1358
|
+
action_fields,
|
|
1359
|
+
action_fields | {"a", "d"},
|
|
1360
|
+
)
|
|
1361
|
+
if not any(
|
|
1362
|
+
set(payload) == common | scope_fields | fields
|
|
1363
|
+
for fields in allowed_action_fields
|
|
1364
|
+
):
|
|
1365
|
+
raise CardActionError("本轮文件动作字段不完整或包含未知字段。")
|
|
1366
|
+
if payload["chat_id"] != callback_chat_id:
|
|
1367
|
+
raise CardActionError("本轮文件卡片与当前聊天不一致。")
|
|
1368
|
+
scope = _scope_from_envelope(
|
|
1369
|
+
app_id=app_id,
|
|
1370
|
+
chat_id=callback_chat_id,
|
|
1371
|
+
kind=scope_kind,
|
|
1372
|
+
topic_id=payload.get("topic_id"),
|
|
1373
|
+
)
|
|
1374
|
+
binding_id = _decode_binding_reference(
|
|
1375
|
+
_required_string(payload["binding_id"], "binding_id")
|
|
1376
|
+
)
|
|
1377
|
+
turn_id = _decode_turn_reference(
|
|
1378
|
+
_required_string(payload["turn_id"], "turn_id")
|
|
1379
|
+
)
|
|
1380
|
+
page = None
|
|
1381
|
+
path = None
|
|
1382
|
+
files: tuple[TurnFileManifestItem, ...] = ()
|
|
1383
|
+
answer = None
|
|
1384
|
+
progress = None
|
|
1385
|
+
reply = None
|
|
1386
|
+
additions = None
|
|
1387
|
+
deletions = None
|
|
1388
|
+
if name is TurnFileActionName.PAGE:
|
|
1389
|
+
raw_page = payload["page"]
|
|
1390
|
+
if (
|
|
1391
|
+
isinstance(raw_page, bool)
|
|
1392
|
+
or not isinstance(raw_page, int)
|
|
1393
|
+
or raw_page < 0
|
|
1394
|
+
):
|
|
1395
|
+
raise CardActionError("本轮文件页码必须是非负整数。")
|
|
1396
|
+
page = raw_page
|
|
1397
|
+
files = _decode_turn_file_manifest(payload["files"])
|
|
1398
|
+
total_pages = (len(files) + TURN_FILE_PAGE_SIZE - 1) // TURN_FILE_PAGE_SIZE
|
|
1399
|
+
if raw_page >= total_pages:
|
|
1400
|
+
raise CardActionError("本轮文件页码超出范围。")
|
|
1401
|
+
if select_page:
|
|
1402
|
+
if (
|
|
1403
|
+
not isinstance(form_value, Mapping)
|
|
1404
|
+
or set(form_value) != {"turn_file_page"}
|
|
1405
|
+
):
|
|
1406
|
+
raise CardActionError("本轮文件选页表单字段无效。")
|
|
1407
|
+
selection = form_value["turn_file_page"]
|
|
1408
|
+
# Match the exact advertised ASCII values; reject coercion,
|
|
1409
|
+
# oversized numbers and options outside this complete manifest.
|
|
1410
|
+
if not isinstance(selection, str) or selection not in {
|
|
1411
|
+
str(index) for index in range(total_pages)
|
|
1412
|
+
}:
|
|
1413
|
+
raise CardActionError("本轮文件页码超出范围。")
|
|
1414
|
+
page = int(selection)
|
|
1415
|
+
if "a" in payload:
|
|
1416
|
+
try:
|
|
1417
|
+
additions, deletions = _optional_line_counts(
|
|
1418
|
+
payload["a"],
|
|
1419
|
+
payload["d"],
|
|
1420
|
+
field="files",
|
|
1421
|
+
)
|
|
1422
|
+
except ValueError as error:
|
|
1423
|
+
raise CardActionError("本轮文件总行数统计无效。") from error
|
|
1424
|
+
if version == TURN_FILE_ACTION_VERSION:
|
|
1425
|
+
answer = _required_string(payload["answer"], "answer")
|
|
1426
|
+
if len(answer) > 100_000 or "\x00" in answer:
|
|
1427
|
+
raise CardActionError("本轮文件卡片回答内容无效。")
|
|
1428
|
+
if "progress" in payload:
|
|
1429
|
+
progress = _decode_turn_progress_manifest(payload["progress"])
|
|
1430
|
+
else:
|
|
1431
|
+
reply = _decode_reply_card_manifest(
|
|
1432
|
+
payload["reply"],
|
|
1433
|
+
binding_id=binding_id,
|
|
1434
|
+
)
|
|
1435
|
+
if reply.result is None:
|
|
1436
|
+
raise CardActionError("组合回复分页缺少结果模块。")
|
|
1437
|
+
answer = reply.result.content
|
|
1438
|
+
progress = (
|
|
1439
|
+
reply.activity.progress if reply.activity is not None else None
|
|
1440
|
+
)
|
|
1441
|
+
else:
|
|
1442
|
+
path = _decode_turn_file_path(payload["path"])
|
|
1443
|
+
return TurnFileActionIntent(
|
|
1444
|
+
scope=scope,
|
|
1445
|
+
source_id=message_id,
|
|
1446
|
+
sender_id=sender_id,
|
|
1447
|
+
name=name,
|
|
1448
|
+
binding_id=binding_id,
|
|
1449
|
+
turn_id=turn_id,
|
|
1450
|
+
page=page,
|
|
1451
|
+
path=path,
|
|
1452
|
+
files=files,
|
|
1453
|
+
answer=answer,
|
|
1454
|
+
progress=progress,
|
|
1455
|
+
reply=reply,
|
|
1456
|
+
additions=additions,
|
|
1457
|
+
deletions=deletions,
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def _decode_turn_file_manifest(value: Any) -> tuple[TurnFileManifestItem, ...]:
|
|
1462
|
+
if (
|
|
1463
|
+
not isinstance(value, list)
|
|
1464
|
+
or not value
|
|
1465
|
+
or len(value) > TURN_FILE_MANIFEST_LIMIT
|
|
1466
|
+
):
|
|
1467
|
+
raise CardActionError(
|
|
1468
|
+
f"本轮文件清单必须包含 1–{TURN_FILE_MANIFEST_LIMIT} 个文件。"
|
|
1469
|
+
)
|
|
1470
|
+
results: list[TurnFileManifestItem] = []
|
|
1471
|
+
seen: set[str] = set()
|
|
1472
|
+
for item in value:
|
|
1473
|
+
if not isinstance(item, Mapping) or set(item) - {"deleted"} not in (
|
|
1474
|
+
{"path", "label"},
|
|
1475
|
+
{"path", "label", "a", "d"},
|
|
1476
|
+
):
|
|
1477
|
+
raise CardActionError("本轮文件清单条目字段无效。")
|
|
1478
|
+
deleted = item.get("deleted", False)
|
|
1479
|
+
if type(deleted) is not bool:
|
|
1480
|
+
raise CardActionError("本轮文件删除标记无效。")
|
|
1481
|
+
path = _decode_turn_file_path(item["path"])
|
|
1482
|
+
label = _required_string(item["label"], "label")
|
|
1483
|
+
if len(label) > 1024 or "\x00" in label:
|
|
1484
|
+
raise CardActionError("本轮文件显示名称无效。")
|
|
1485
|
+
if path in seen:
|
|
1486
|
+
raise CardActionError("本轮文件清单包含重复路径。")
|
|
1487
|
+
seen.add(path)
|
|
1488
|
+
additions = None
|
|
1489
|
+
deletions = None
|
|
1490
|
+
if "a" in item:
|
|
1491
|
+
try:
|
|
1492
|
+
additions, deletions = _optional_line_counts(
|
|
1493
|
+
item["a"],
|
|
1494
|
+
item["d"],
|
|
1495
|
+
field="file",
|
|
1496
|
+
)
|
|
1497
|
+
except ValueError as error:
|
|
1498
|
+
raise CardActionError("本轮文件行数统计无效。") from error
|
|
1499
|
+
results.append(
|
|
1500
|
+
TurnFileManifestItem(
|
|
1501
|
+
path=path,
|
|
1502
|
+
label=label,
|
|
1503
|
+
additions=additions,
|
|
1504
|
+
deletions=deletions,
|
|
1505
|
+
deleted=deleted,
|
|
1506
|
+
)
|
|
1507
|
+
)
|
|
1508
|
+
return tuple(results)
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def _decode_turn_progress_manifest(value: Any) -> TurnProgressManifest:
|
|
1512
|
+
if not isinstance(value, Mapping):
|
|
1513
|
+
raise CardActionError("进度卡过程字段无效。")
|
|
1514
|
+
payload = dict(value)
|
|
1515
|
+
expected = {
|
|
1516
|
+
"state",
|
|
1517
|
+
"steer_count",
|
|
1518
|
+
"plan_available",
|
|
1519
|
+
"plan_generated",
|
|
1520
|
+
"plan_may_be_stale",
|
|
1521
|
+
"steps",
|
|
1522
|
+
"commentary",
|
|
1523
|
+
"operations",
|
|
1524
|
+
}
|
|
1525
|
+
if set(payload) != expected:
|
|
1526
|
+
raise CardActionError("进度卡过程字段不完整或包含未知字段。")
|
|
1527
|
+
state = _required_string(payload["state"], "progress.state")
|
|
1528
|
+
if state not in ACTIVE_STATE_VALUES:
|
|
1529
|
+
raise CardActionError("进度卡过程状态无效。")
|
|
1530
|
+
steer_count = payload["steer_count"]
|
|
1531
|
+
if (
|
|
1532
|
+
isinstance(steer_count, bool)
|
|
1533
|
+
or not isinstance(steer_count, int)
|
|
1534
|
+
or not 0 <= steer_count <= 1_000_000
|
|
1535
|
+
):
|
|
1536
|
+
raise CardActionError("进度卡调整次数无效。")
|
|
1537
|
+
flags: dict[str, bool] = {}
|
|
1538
|
+
for field in (
|
|
1539
|
+
"plan_available",
|
|
1540
|
+
"plan_generated",
|
|
1541
|
+
"plan_may_be_stale",
|
|
1542
|
+
):
|
|
1543
|
+
raw = payload[field]
|
|
1544
|
+
if type(raw) is not bool:
|
|
1545
|
+
raise CardActionError("进度卡计划状态无效。")
|
|
1546
|
+
flags[field] = raw
|
|
1547
|
+
raw_steps = payload["steps"]
|
|
1548
|
+
if not isinstance(raw_steps, list) or len(raw_steps) > _TURN_PROGRESS_MAX_STEPS:
|
|
1549
|
+
raise CardActionError("进度卡计划步骤无效。")
|
|
1550
|
+
steps: list[TurnProgressManifestStep] = []
|
|
1551
|
+
for raw in raw_steps:
|
|
1552
|
+
if not isinstance(raw, Mapping) or set(raw) != {"step", "status"}:
|
|
1553
|
+
raise CardActionError("进度卡计划步骤字段无效。")
|
|
1554
|
+
step = _required_string(raw["step"], "progress.step")
|
|
1555
|
+
if len(step) > _TURN_PROGRESS_STEP_MAX_CHARS or "\x00" in step:
|
|
1556
|
+
raise CardActionError("进度卡计划步骤内容无效。")
|
|
1557
|
+
status = _required_string(raw["status"], "progress.status")
|
|
1558
|
+
if status not in {"pending", "inProgress", "completed"}:
|
|
1559
|
+
raise CardActionError("进度卡计划步骤状态无效。")
|
|
1560
|
+
steps.append(
|
|
1561
|
+
TurnProgressManifestStep(
|
|
1562
|
+
step=activity_step_display(step),
|
|
1563
|
+
status=status,
|
|
1564
|
+
)
|
|
1565
|
+
)
|
|
1566
|
+
if not flags["plan_generated"] and steps:
|
|
1567
|
+
raise CardActionError("未生成计划的进度卡不能携带步骤。")
|
|
1568
|
+
raw_commentary = payload["commentary"]
|
|
1569
|
+
if (
|
|
1570
|
+
not isinstance(raw_commentary, list)
|
|
1571
|
+
or len(raw_commentary) > ACTIVITY_COMMENTARY_LIMIT
|
|
1572
|
+
):
|
|
1573
|
+
raise CardActionError("进度卡进展摘要无效。")
|
|
1574
|
+
commentary: list[TurnCommentaryManifestEntry] = []
|
|
1575
|
+
for raw in raw_commentary:
|
|
1576
|
+
event_timestamp_ms: int | None
|
|
1577
|
+
if isinstance(raw, str):
|
|
1578
|
+
text = _required_string(raw, "progress.commentary")
|
|
1579
|
+
event_timestamp_ms = None
|
|
1580
|
+
elif isinstance(raw, Mapping) and set(raw) == {
|
|
1581
|
+
"text",
|
|
1582
|
+
"event_timestamp_ms",
|
|
1583
|
+
}:
|
|
1584
|
+
text = _required_string(raw["text"], "progress.commentary.text")
|
|
1585
|
+
event_timestamp_ms = _decode_activity_timestamp_ms(
|
|
1586
|
+
raw["event_timestamp_ms"],
|
|
1587
|
+
"progress.commentary.event_timestamp_ms",
|
|
1588
|
+
)
|
|
1589
|
+
else:
|
|
1590
|
+
raise CardActionError("进度卡进展摘要字段无效。")
|
|
1591
|
+
sanitized = sanitize_activity_text(text)
|
|
1592
|
+
if sanitized is None or len(text) > _TURN_PROGRESS_STEP_MAX_CHARS:
|
|
1593
|
+
raise CardActionError("进度卡进展摘要内容无效。")
|
|
1594
|
+
commentary.append(
|
|
1595
|
+
TurnCommentaryManifestEntry(
|
|
1596
|
+
text=sanitized,
|
|
1597
|
+
event_timestamp_ms=event_timestamp_ms,
|
|
1598
|
+
)
|
|
1599
|
+
)
|
|
1600
|
+
raw_operations = payload["operations"]
|
|
1601
|
+
if (
|
|
1602
|
+
not isinstance(raw_operations, list)
|
|
1603
|
+
or len(raw_operations) > ACTIVITY_OPERATION_LIMIT
|
|
1604
|
+
):
|
|
1605
|
+
raise CardActionError("进度卡操作清单无效。")
|
|
1606
|
+
operations: list[TurnActivityManifestEntry] = []
|
|
1607
|
+
allowed_kinds = {
|
|
1608
|
+
kind.value for kind in TurnActivityKind if kind is not TurnActivityKind.COMMENTARY
|
|
1609
|
+
}
|
|
1610
|
+
allowed_statuses = {status.value for status in TurnActivityStatus}
|
|
1611
|
+
for raw in raw_operations:
|
|
1612
|
+
if not isinstance(raw, Mapping):
|
|
1613
|
+
raise CardActionError("进度卡操作字段无效。")
|
|
1614
|
+
fields = set(raw)
|
|
1615
|
+
legacy_fields = {"kind", "status", "text", "count"}
|
|
1616
|
+
timestamped_fields = legacy_fields | {"event_timestamp_ms"}
|
|
1617
|
+
if frozenset(fields) not in {
|
|
1618
|
+
frozenset(legacy_fields),
|
|
1619
|
+
frozenset(timestamped_fields),
|
|
1620
|
+
}:
|
|
1621
|
+
raise CardActionError("进度卡操作字段无效。")
|
|
1622
|
+
kind = _required_string(raw["kind"], "progress.operation.kind")
|
|
1623
|
+
status = _required_string(raw["status"], "progress.operation.status")
|
|
1624
|
+
if kind not in allowed_kinds or status not in allowed_statuses:
|
|
1625
|
+
raise CardActionError("进度卡操作类型或状态无效。")
|
|
1626
|
+
text_value = raw["text"]
|
|
1627
|
+
text: str | None
|
|
1628
|
+
if text_value is None:
|
|
1629
|
+
text = None
|
|
1630
|
+
else:
|
|
1631
|
+
text = _required_string(text_value, "progress.operation.text")
|
|
1632
|
+
try:
|
|
1633
|
+
text = _activity_operation_text(kind, text)
|
|
1634
|
+
except ValueError as error:
|
|
1635
|
+
raise CardActionError("进度卡操作内容无效。") from error
|
|
1636
|
+
event_timestamp_ms = (
|
|
1637
|
+
_decode_activity_timestamp_ms(
|
|
1638
|
+
raw["event_timestamp_ms"],
|
|
1639
|
+
"progress.operation.event_timestamp_ms",
|
|
1640
|
+
)
|
|
1641
|
+
if "event_timestamp_ms" in raw
|
|
1642
|
+
else None
|
|
1643
|
+
)
|
|
1644
|
+
count = raw["count"]
|
|
1645
|
+
if isinstance(count, bool) or not isinstance(count, int) or not 0 <= count <= 1_000_000:
|
|
1646
|
+
raise CardActionError("进度卡操作数量无效。")
|
|
1647
|
+
operations.append(
|
|
1648
|
+
TurnActivityManifestEntry(
|
|
1649
|
+
kind=kind,
|
|
1650
|
+
status=status,
|
|
1651
|
+
event_timestamp_ms=event_timestamp_ms,
|
|
1652
|
+
text=text,
|
|
1653
|
+
count=count,
|
|
1654
|
+
)
|
|
1655
|
+
)
|
|
1656
|
+
return TurnProgressManifest(
|
|
1657
|
+
state=state,
|
|
1658
|
+
steer_count=steer_count,
|
|
1659
|
+
plan_available=flags["plan_available"],
|
|
1660
|
+
plan_generated=flags["plan_generated"],
|
|
1661
|
+
plan_may_be_stale=flags["plan_may_be_stale"],
|
|
1662
|
+
steps=tuple(steps),
|
|
1663
|
+
commentary=tuple(commentary),
|
|
1664
|
+
operations=tuple(operations),
|
|
1665
|
+
)
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def _decode_reply_card_manifest(
|
|
1669
|
+
value: Any,
|
|
1670
|
+
*,
|
|
1671
|
+
binding_id: str,
|
|
1672
|
+
) -> ReplyCardManifest:
|
|
1673
|
+
if not isinstance(value, Mapping) or set(value) != {
|
|
1674
|
+
"goal",
|
|
1675
|
+
"activity",
|
|
1676
|
+
"result",
|
|
1677
|
+
}:
|
|
1678
|
+
raise CardActionError("组合回复清单字段不完整或包含未知字段。")
|
|
1679
|
+
goal = _decode_reply_goal_module(value["goal"], binding_id=binding_id)
|
|
1680
|
+
activity = _decode_reply_activity_module(value["activity"])
|
|
1681
|
+
result = _decode_reply_result_module(value["result"])
|
|
1682
|
+
if goal is None and activity is None and result is None:
|
|
1683
|
+
raise CardActionError("组合回复清单不能为空。")
|
|
1684
|
+
return ReplyCardManifest(goal=goal, activity=activity, result=result)
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def _decode_reply_goal_module(
|
|
1688
|
+
value: Any,
|
|
1689
|
+
*,
|
|
1690
|
+
binding_id: str,
|
|
1691
|
+
) -> ReplyCardGoalModule | None:
|
|
1692
|
+
if value is None:
|
|
1693
|
+
return None
|
|
1694
|
+
expected = {
|
|
1695
|
+
"binding_id",
|
|
1696
|
+
"short_id",
|
|
1697
|
+
"project_alias",
|
|
1698
|
+
"goal_generation",
|
|
1699
|
+
"status",
|
|
1700
|
+
"runtime_state",
|
|
1701
|
+
"objective",
|
|
1702
|
+
"token_budget",
|
|
1703
|
+
"tokens_used",
|
|
1704
|
+
"notice",
|
|
1705
|
+
"notice_is_error",
|
|
1706
|
+
}
|
|
1707
|
+
if not isinstance(value, Mapping) or set(value) != expected:
|
|
1708
|
+
raise CardActionError("Goal 模块字段不完整或包含未知字段。")
|
|
1709
|
+
manifest_binding_id = _decode_binding_reference(
|
|
1710
|
+
_required_string(value["binding_id"], "goal.binding_id")
|
|
1711
|
+
)
|
|
1712
|
+
if manifest_binding_id != binding_id:
|
|
1713
|
+
raise CardActionError("Goal 与 Files 模块的会话身份不一致。")
|
|
1714
|
+
status = value["status"]
|
|
1715
|
+
if status is not None:
|
|
1716
|
+
status = _required_string(status, "goal.status")
|
|
1717
|
+
if status not in {item.value for item in GoalStatus}:
|
|
1718
|
+
raise CardActionError("Goal 模块状态无效。")
|
|
1719
|
+
generation = value["goal_generation"]
|
|
1720
|
+
if status is None:
|
|
1721
|
+
if generation is not None:
|
|
1722
|
+
raise CardActionError("空 Goal 模块不能携带 generation。")
|
|
1723
|
+
else:
|
|
1724
|
+
generation = _decode_goal_generation(generation)
|
|
1725
|
+
runtime_state = _optional_bounded_string(
|
|
1726
|
+
value["runtime_state"], "goal.runtime_state", 128
|
|
1727
|
+
)
|
|
1728
|
+
objective = _optional_bounded_string(
|
|
1729
|
+
value["objective"], "goal.objective", 10_000
|
|
1730
|
+
)
|
|
1731
|
+
token_budget = _optional_nonnegative_int(
|
|
1732
|
+
value["token_budget"], "goal.token_budget"
|
|
1733
|
+
)
|
|
1734
|
+
tokens_used = _decode_nonnegative_int(value["tokens_used"], "goal.tokens_used")
|
|
1735
|
+
notice = _optional_bounded_string(value["notice"], "goal.notice", 4_000)
|
|
1736
|
+
notice_is_error = value["notice_is_error"]
|
|
1737
|
+
if type(notice_is_error) is not bool:
|
|
1738
|
+
raise CardActionError("Goal 模块 notice_is_error 无效。")
|
|
1739
|
+
if status is None and any(
|
|
1740
|
+
item is not None
|
|
1741
|
+
for item in (runtime_state, objective, token_budget)
|
|
1742
|
+
):
|
|
1743
|
+
raise CardActionError("空 Goal 模块不能携带 Goal 状态。")
|
|
1744
|
+
if status is None and tokens_used != 0:
|
|
1745
|
+
raise CardActionError("空 Goal 模块不能携带 Token 用量。")
|
|
1746
|
+
if status is not None and objective is None:
|
|
1747
|
+
raise CardActionError("Goal 模块缺少 Objective。")
|
|
1748
|
+
goal = ReplyCardGoalModule(
|
|
1749
|
+
binding_id=binding_id,
|
|
1750
|
+
short_id=_bounded_decode_string(value["short_id"], "goal.short_id", 32),
|
|
1751
|
+
project_alias=_bounded_decode_string(
|
|
1752
|
+
value["project_alias"], "goal.project_alias", 128
|
|
1753
|
+
),
|
|
1754
|
+
goal_generation=generation,
|
|
1755
|
+
status=status,
|
|
1756
|
+
runtime_state=runtime_state,
|
|
1757
|
+
objective=objective,
|
|
1758
|
+
token_budget=token_budget,
|
|
1759
|
+
tokens_used=tokens_used,
|
|
1760
|
+
notice=notice,
|
|
1761
|
+
notice_is_error=notice_is_error,
|
|
1762
|
+
)
|
|
1763
|
+
try:
|
|
1764
|
+
return _normalize_goal_module(goal)
|
|
1765
|
+
except ValueError as error:
|
|
1766
|
+
raise CardActionError("Goal 模块内容无效。") from error
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
def _decode_reply_activity_module(value: Any) -> ReplyCardActivityModule | None:
|
|
1770
|
+
if value is None:
|
|
1771
|
+
return None
|
|
1772
|
+
if not isinstance(value, Mapping) or set(value) != {
|
|
1773
|
+
"progress",
|
|
1774
|
+
"terminal_status",
|
|
1775
|
+
"collapsed",
|
|
1776
|
+
"hidden_steps",
|
|
1777
|
+
}:
|
|
1778
|
+
raise CardActionError("Activity 模块字段不完整或包含未知字段。")
|
|
1779
|
+
progress = _decode_turn_progress_manifest(value["progress"])
|
|
1780
|
+
terminal_status = value["terminal_status"]
|
|
1781
|
+
if terminal_status is not None:
|
|
1782
|
+
terminal_status = _required_string(
|
|
1783
|
+
terminal_status, "activity.terminal_status"
|
|
1784
|
+
)
|
|
1785
|
+
try:
|
|
1786
|
+
terminal_status = _terminal_progress_status(terminal_status)
|
|
1787
|
+
except ValueError as error:
|
|
1788
|
+
raise CardActionError("Activity 模块终态无效。") from error
|
|
1789
|
+
collapsed = value["collapsed"]
|
|
1790
|
+
if type(collapsed) is not bool:
|
|
1791
|
+
raise CardActionError("Activity 模块折叠状态无效。")
|
|
1792
|
+
if collapsed != (terminal_status is not None):
|
|
1793
|
+
raise CardActionError("Activity 模块折叠状态与终态不一致。")
|
|
1794
|
+
hidden_steps = _decode_nonnegative_int(
|
|
1795
|
+
value["hidden_steps"], "activity.hidden_steps"
|
|
1796
|
+
)
|
|
1797
|
+
return ReplyCardActivityModule(
|
|
1798
|
+
progress=progress,
|
|
1799
|
+
terminal_status=terminal_status,
|
|
1800
|
+
collapsed=collapsed,
|
|
1801
|
+
hidden_steps=hidden_steps,
|
|
1802
|
+
)
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def _decode_reply_result_module(value: Any) -> ReplyCardResultModule | None:
|
|
1806
|
+
if value is None:
|
|
1807
|
+
return None
|
|
1808
|
+
if not isinstance(value, Mapping) or set(value) != {"content"}:
|
|
1809
|
+
raise CardActionError("Result 模块字段不完整或包含未知字段。")
|
|
1810
|
+
return ReplyCardResultModule(
|
|
1811
|
+
_bounded_decode_string(value["content"], "result.content", 100_000)
|
|
1812
|
+
)
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
def _bounded_decode_string(value: Any, field: str, limit: int) -> str:
|
|
1816
|
+
result = _required_string(value, field)
|
|
1817
|
+
if len(result) > limit or "\x00" in result:
|
|
1818
|
+
raise CardActionError(f"{field} 内容无效。")
|
|
1819
|
+
return result
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
def _optional_bounded_string(
|
|
1823
|
+
value: Any,
|
|
1824
|
+
field: str,
|
|
1825
|
+
limit: int,
|
|
1826
|
+
) -> str | None:
|
|
1827
|
+
if value is None:
|
|
1828
|
+
return None
|
|
1829
|
+
return _bounded_decode_string(value, field, limit)
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
def _decode_nonnegative_int(value: Any, field: str) -> int:
|
|
1833
|
+
if (
|
|
1834
|
+
isinstance(value, bool)
|
|
1835
|
+
or not isinstance(value, int)
|
|
1836
|
+
or not 0 <= value <= 10**18
|
|
1837
|
+
):
|
|
1838
|
+
raise CardActionError(f"{field} 数值无效。")
|
|
1839
|
+
return value
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
def _optional_nonnegative_int(value: Any, field: str) -> int | None:
|
|
1843
|
+
if value is None:
|
|
1844
|
+
return None
|
|
1845
|
+
return _decode_nonnegative_int(value, field)
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def _decode_turn_file_path(value: Any) -> str:
|
|
1849
|
+
path = _required_string(value, "path")
|
|
1850
|
+
if len(path) > 8192 or "\x00" in path or not Path(path).is_absolute():
|
|
1851
|
+
raise CardActionError("本轮文件路径必须是有效的绝对路径。")
|
|
1852
|
+
return path
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
def _turn_answer_block(result: ReplyCardResultModule) -> dict[str, Any]:
|
|
1856
|
+
elements: list[dict[str, Any]] = [
|
|
1857
|
+
{"tag": "markdown", "content": result.content},
|
|
1858
|
+
]
|
|
1859
|
+
if result.completion_mention_user_id is not None:
|
|
1860
|
+
elements.append({
|
|
1861
|
+
"tag": "markdown",
|
|
1862
|
+
"content": f"<at id={result.completion_mention_user_id}></at>",
|
|
1863
|
+
})
|
|
1864
|
+
return {
|
|
1865
|
+
"tag": "column_set",
|
|
1866
|
+
"element_id": _TURN_ANSWER_ELEMENT_ID,
|
|
1867
|
+
"flex_mode": "none",
|
|
1868
|
+
"background_style": "default",
|
|
1869
|
+
"columns": [
|
|
1870
|
+
{
|
|
1871
|
+
"tag": "column",
|
|
1872
|
+
"width": "weighted",
|
|
1873
|
+
"weight": 1,
|
|
1874
|
+
"padding": "12px",
|
|
1875
|
+
"vertical_spacing": "4px",
|
|
1876
|
+
"elements": elements,
|
|
1877
|
+
}
|
|
1878
|
+
],
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def _turn_files_blocks(
|
|
1883
|
+
*,
|
|
1884
|
+
scope: FeishuScope,
|
|
1885
|
+
binding_id: str,
|
|
1886
|
+
turn_id: str,
|
|
1887
|
+
page: TurnFilePage,
|
|
1888
|
+
manifest: tuple[TurnFileManifestItem, ...],
|
|
1889
|
+
final_response: str,
|
|
1890
|
+
progress: TurnProgressManifest | None = None,
|
|
1891
|
+
reply: ReplyCardManifest | None = None,
|
|
1892
|
+
action_version: int = TURN_FILE_ACTION_VERSION,
|
|
1893
|
+
additions: int | None = None,
|
|
1894
|
+
deletions: int | None = None,
|
|
1895
|
+
) -> tuple[dict[str, Any], ...]:
|
|
1896
|
+
line_counts = ""
|
|
1897
|
+
if additions is not None and deletions is not None:
|
|
1898
|
+
line_counts = (
|
|
1899
|
+
"\n累计修改 "
|
|
1900
|
+
f"<font color='green'>+{additions}</font> "
|
|
1901
|
+
f"<font color='red'>-{deletions}</font>"
|
|
1902
|
+
)
|
|
1903
|
+
elements: list[dict[str, Any]] = [
|
|
1904
|
+
{
|
|
1905
|
+
"tag": "markdown",
|
|
1906
|
+
"content": (
|
|
1907
|
+
f"**本轮文件** · 共 {page.total_items} 个 · "
|
|
1908
|
+
f"第 {page.page + 1}/{page.total_pages} 页"
|
|
1909
|
+
f"{line_counts}\n"
|
|
1910
|
+
"<font color='grey'>行数累计成功的文件修改,重复修改会重复计数;"
|
|
1911
|
+
"统计不完整时省略总计或相应文件的数字。</font>\n"
|
|
1912
|
+
"<font color='grey'>点击“发送”后,文件将以图片或文件消息发送到本卡片话题。</font>"
|
|
1913
|
+
),
|
|
1914
|
+
}
|
|
1915
|
+
]
|
|
1916
|
+
elements.extend(
|
|
1917
|
+
_turn_file_row(
|
|
1918
|
+
scope=scope,
|
|
1919
|
+
binding_id=binding_id,
|
|
1920
|
+
turn_id=turn_id,
|
|
1921
|
+
turn_file=turn_file,
|
|
1922
|
+
action_version=action_version,
|
|
1923
|
+
)
|
|
1924
|
+
for turn_file in page.items
|
|
1925
|
+
)
|
|
1926
|
+
navigation = None
|
|
1927
|
+
if page.total_pages > 1:
|
|
1928
|
+
navigation = _turn_file_pagination(
|
|
1929
|
+
scope=scope,
|
|
1930
|
+
binding_id=binding_id,
|
|
1931
|
+
turn_id=turn_id,
|
|
1932
|
+
page=page.page,
|
|
1933
|
+
total_pages=page.total_pages,
|
|
1934
|
+
manifest=manifest,
|
|
1935
|
+
final_response=final_response,
|
|
1936
|
+
progress=progress,
|
|
1937
|
+
reply=reply,
|
|
1938
|
+
action_version=action_version,
|
|
1939
|
+
additions=additions,
|
|
1940
|
+
deletions=deletions,
|
|
1941
|
+
)
|
|
1942
|
+
block = {
|
|
1943
|
+
"tag": "column_set",
|
|
1944
|
+
"element_id": _TURN_FILES_ELEMENT_ID,
|
|
1945
|
+
"flex_mode": "none",
|
|
1946
|
+
"background_style": "grey-50",
|
|
1947
|
+
"columns": [
|
|
1948
|
+
{
|
|
1949
|
+
"tag": "column",
|
|
1950
|
+
"width": "weighted",
|
|
1951
|
+
"weight": 1,
|
|
1952
|
+
"padding": "12px",
|
|
1953
|
+
"vertical_spacing": "8px",
|
|
1954
|
+
"elements": elements,
|
|
1955
|
+
}
|
|
1956
|
+
],
|
|
1957
|
+
}
|
|
1958
|
+
# Feishu only permits forms at the card root. Keep file SEND buttons
|
|
1959
|
+
# outside the form so they do not submit or validate its page selector.
|
|
1960
|
+
if navigation is not None:
|
|
1961
|
+
return block, navigation
|
|
1962
|
+
return (block,)
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
def _turn_file_row(
|
|
1966
|
+
*,
|
|
1967
|
+
scope: FeishuScope,
|
|
1968
|
+
binding_id: str,
|
|
1969
|
+
turn_id: str,
|
|
1970
|
+
turn_file: TurnFile,
|
|
1971
|
+
action_version: int = TURN_FILE_ACTION_VERSION,
|
|
1972
|
+
) -> dict[str, Any]:
|
|
1973
|
+
line_counts = ""
|
|
1974
|
+
if (
|
|
1975
|
+
(turn_file.media_kind == "file" or turn_file.deleted)
|
|
1976
|
+
and turn_file.additions is not None
|
|
1977
|
+
and turn_file.deletions is not None
|
|
1978
|
+
):
|
|
1979
|
+
line_counts = (
|
|
1980
|
+
" "
|
|
1981
|
+
f"<font color='green'>+{turn_file.additions}</font> "
|
|
1982
|
+
f"<font color='red'>-{turn_file.deletions}</font>"
|
|
1983
|
+
)
|
|
1984
|
+
if not turn_file.available:
|
|
1985
|
+
icon = "📄" if turn_file.deleted else "⚠️"
|
|
1986
|
+
status = "已删除" if turn_file.deleted else "文件当前不可用"
|
|
1987
|
+
return {
|
|
1988
|
+
"tag": "column_set",
|
|
1989
|
+
"flex_mode": "none",
|
|
1990
|
+
"background_style": "default",
|
|
1991
|
+
"columns": [
|
|
1992
|
+
{
|
|
1993
|
+
"tag": "column",
|
|
1994
|
+
"width": "weighted",
|
|
1995
|
+
"weight": 1,
|
|
1996
|
+
"padding": "8px",
|
|
1997
|
+
"vertical_spacing": "2px",
|
|
1998
|
+
"elements": [
|
|
1999
|
+
{
|
|
2000
|
+
"tag": "markdown",
|
|
2001
|
+
"content": (
|
|
2002
|
+
f"{icon} `{_turn_file_label(turn_file.display_path)}`{line_counts}\n"
|
|
2003
|
+
f"<font color='grey'>{status}</font>"
|
|
2004
|
+
),
|
|
2005
|
+
}
|
|
2006
|
+
],
|
|
2007
|
+
}
|
|
2008
|
+
],
|
|
2009
|
+
}
|
|
2010
|
+
assert turn_file.size is not None
|
|
2011
|
+
assert turn_file.media_kind is not None
|
|
2012
|
+
icon = "🖼️" if turn_file.media_kind == "image" else "📄"
|
|
2013
|
+
return {
|
|
2014
|
+
"tag": "column_set",
|
|
2015
|
+
"flex_mode": "none",
|
|
2016
|
+
"background_style": "default",
|
|
2017
|
+
"columns": [
|
|
2018
|
+
{
|
|
2019
|
+
"tag": "column",
|
|
2020
|
+
"width": "weighted",
|
|
2021
|
+
"weight": 5,
|
|
2022
|
+
"padding": "8px",
|
|
2023
|
+
"vertical_spacing": "2px",
|
|
2024
|
+
"elements": [
|
|
2025
|
+
{
|
|
2026
|
+
"tag": "markdown",
|
|
2027
|
+
"content": f"{icon} `{_turn_file_label(turn_file.display_path)}`{line_counts}",
|
|
2028
|
+
}
|
|
2029
|
+
],
|
|
2030
|
+
},
|
|
2031
|
+
{
|
|
2032
|
+
"tag": "column",
|
|
2033
|
+
"width": "auto",
|
|
2034
|
+
"vertical_align": "center",
|
|
2035
|
+
"padding": "8px",
|
|
2036
|
+
"elements": [
|
|
2037
|
+
_callback_button(
|
|
2038
|
+
label="发送",
|
|
2039
|
+
value=_turn_file_envelope(
|
|
2040
|
+
scope,
|
|
2041
|
+
TurnFileActionName.SEND,
|
|
2042
|
+
version=action_version,
|
|
2043
|
+
binding_id=_binding_reference(binding_id),
|
|
2044
|
+
turn_id=_turn_reference(turn_id),
|
|
2045
|
+
path=str(turn_file.resolved_path),
|
|
2046
|
+
),
|
|
2047
|
+
)
|
|
2048
|
+
],
|
|
2049
|
+
},
|
|
2050
|
+
],
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
def _encode_turn_file_manifest_item(
|
|
2055
|
+
item: TurnFileManifestItem,
|
|
2056
|
+
) -> dict[str, Any]:
|
|
2057
|
+
encoded: dict[str, Any] = {"path": item.path, "label": item.label}
|
|
2058
|
+
if item.deleted:
|
|
2059
|
+
encoded["deleted"] = True
|
|
2060
|
+
if item.additions is not None and item.deletions is not None:
|
|
2061
|
+
encoded["a"] = item.additions
|
|
2062
|
+
encoded["d"] = item.deletions
|
|
2063
|
+
return encoded
|
|
2064
|
+
|
|
2065
|
+
|
|
2066
|
+
def _turn_file_pagination(
|
|
2067
|
+
*,
|
|
2068
|
+
scope: FeishuScope,
|
|
2069
|
+
binding_id: str,
|
|
2070
|
+
turn_id: str,
|
|
2071
|
+
page: int,
|
|
2072
|
+
total_pages: int,
|
|
2073
|
+
manifest: tuple[TurnFileManifestItem, ...],
|
|
2074
|
+
final_response: str,
|
|
2075
|
+
progress: TurnProgressManifest | None,
|
|
2076
|
+
reply: ReplyCardManifest | None,
|
|
2077
|
+
action_version: int,
|
|
2078
|
+
additions: int | None,
|
|
2079
|
+
deletions: int | None,
|
|
2080
|
+
) -> dict[str, Any]:
|
|
2081
|
+
page_value: dict[str, Any] = {
|
|
2082
|
+
"binding_id": _binding_reference(binding_id),
|
|
2083
|
+
"turn_id": _turn_reference(turn_id),
|
|
2084
|
+
"pagination": "select",
|
|
2085
|
+
"files": [
|
|
2086
|
+
_encode_turn_file_manifest_item(item)
|
|
2087
|
+
for item in manifest
|
|
2088
|
+
],
|
|
2089
|
+
}
|
|
2090
|
+
if additions is not None and deletions is not None:
|
|
2091
|
+
page_value["a"] = additions
|
|
2092
|
+
page_value["d"] = deletions
|
|
2093
|
+
if action_version == TURN_FILE_ACTION_VERSION:
|
|
2094
|
+
page_value["answer"] = final_response
|
|
2095
|
+
if progress is not None:
|
|
2096
|
+
page_value["progress"] = _encode_turn_progress_manifest(progress)
|
|
2097
|
+
else:
|
|
2098
|
+
if reply is None:
|
|
2099
|
+
raise ValueError("v5 pagination requires a Reply Card manifest")
|
|
2100
|
+
page_value["reply"] = _encode_reply_card_manifest(reply)
|
|
2101
|
+
button = _repeatable_callback_button(
|
|
2102
|
+
label="跳转",
|
|
2103
|
+
value=_turn_file_envelope(
|
|
2104
|
+
scope, TurnFileActionName.PAGE,
|
|
2105
|
+
version=action_version, page=page, **page_value,
|
|
2106
|
+
),
|
|
2107
|
+
)
|
|
2108
|
+
button.update(name="turn_file_jump", form_action_type="submit")
|
|
2109
|
+
# The Files header already shows current/total pages. Use the selector
|
|
2110
|
+
# itself as the footer's current-page label, preserving capacity.
|
|
2111
|
+
columns = [
|
|
2112
|
+
{
|
|
2113
|
+
"tag": "column",
|
|
2114
|
+
"width": "auto",
|
|
2115
|
+
"elements": [{
|
|
2116
|
+
"tag": "select_static",
|
|
2117
|
+
"name": "turn_file_page",
|
|
2118
|
+
"required": True,
|
|
2119
|
+
"initial_option": str(page),
|
|
2120
|
+
"placeholder": _plain_text("选择页码"),
|
|
2121
|
+
"options": [
|
|
2122
|
+
{"text": _plain_text(f"第{index + 1}页"), "value": str(index)}
|
|
2123
|
+
for index in range(total_pages)
|
|
2124
|
+
],
|
|
2125
|
+
}],
|
|
2126
|
+
},
|
|
2127
|
+
{"tag": "column", "width": "auto", "elements": [button]},
|
|
2128
|
+
]
|
|
2129
|
+
return {
|
|
2130
|
+
"tag": "form",
|
|
2131
|
+
"name": "turn_file_pagination",
|
|
2132
|
+
"elements": [{"tag": "column_set", "flex_mode": "none", "columns": columns}],
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def _turn_file_envelope(
|
|
2137
|
+
scope: FeishuScope,
|
|
2138
|
+
name: TurnFileActionName,
|
|
2139
|
+
*,
|
|
2140
|
+
version: int = TURN_FILE_ACTION_VERSION,
|
|
2141
|
+
**extra: Any,
|
|
2142
|
+
) -> dict[str, Any]:
|
|
2143
|
+
value = {
|
|
2144
|
+
"v": version,
|
|
2145
|
+
"intent": name.value,
|
|
2146
|
+
"chat_id": scope.chat_id,
|
|
2147
|
+
"scope_kind": scope.kind.value,
|
|
2148
|
+
**extra,
|
|
2149
|
+
}
|
|
2150
|
+
if scope.kind is ScopeKind.TOPIC:
|
|
2151
|
+
value["topic_id"] = scope.topic_id
|
|
2152
|
+
return value
|
|
2153
|
+
|
|
2154
|
+
|
|
2155
|
+
def _encode_turn_progress_manifest(
|
|
2156
|
+
progress: TurnProgressManifest,
|
|
2157
|
+
) -> dict[str, Any]:
|
|
2158
|
+
return {
|
|
2159
|
+
"state": progress.state,
|
|
2160
|
+
"steer_count": progress.steer_count,
|
|
2161
|
+
"plan_available": progress.plan_available,
|
|
2162
|
+
"plan_generated": progress.plan_generated,
|
|
2163
|
+
"plan_may_be_stale": progress.plan_may_be_stale,
|
|
2164
|
+
"steps": [
|
|
2165
|
+
{"step": item.step, "status": item.status}
|
|
2166
|
+
for item in progress.steps
|
|
2167
|
+
],
|
|
2168
|
+
"commentary": [
|
|
2169
|
+
(
|
|
2170
|
+
item.text
|
|
2171
|
+
if item.event_timestamp_ms is None
|
|
2172
|
+
else {
|
|
2173
|
+
"text": item.text,
|
|
2174
|
+
"event_timestamp_ms": item.event_timestamp_ms,
|
|
2175
|
+
}
|
|
2176
|
+
)
|
|
2177
|
+
for item in progress.commentary
|
|
2178
|
+
],
|
|
2179
|
+
"operations": [
|
|
2180
|
+
{
|
|
2181
|
+
"kind": item.kind,
|
|
2182
|
+
"status": item.status,
|
|
2183
|
+
"text": item.text,
|
|
2184
|
+
"count": item.count,
|
|
2185
|
+
**(
|
|
2186
|
+
{}
|
|
2187
|
+
if item.event_timestamp_ms is None
|
|
2188
|
+
else {"event_timestamp_ms": item.event_timestamp_ms}
|
|
2189
|
+
),
|
|
2190
|
+
}
|
|
2191
|
+
for item in progress.operations
|
|
2192
|
+
],
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
def _encode_reply_card_manifest(
|
|
2197
|
+
reply: ReplyCardManifest,
|
|
2198
|
+
) -> dict[str, Any]:
|
|
2199
|
+
return {
|
|
2200
|
+
"goal": _encode_reply_goal_module(reply.goal),
|
|
2201
|
+
"activity": _encode_reply_activity_module(reply.activity),
|
|
2202
|
+
"result": (
|
|
2203
|
+
None if reply.result is None else {"content": reply.result.content}
|
|
2204
|
+
),
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
|
|
2208
|
+
def _encode_reply_goal_module(
|
|
2209
|
+
goal: ReplyCardGoalModule | None,
|
|
2210
|
+
) -> dict[str, Any] | None:
|
|
2211
|
+
if goal is None:
|
|
2212
|
+
return None
|
|
2213
|
+
return {
|
|
2214
|
+
"binding_id": _binding_reference(goal.binding_id),
|
|
2215
|
+
"short_id": goal.short_id,
|
|
2216
|
+
"project_alias": goal.project_alias,
|
|
2217
|
+
"goal_generation": goal.goal_generation,
|
|
2218
|
+
"status": goal.status,
|
|
2219
|
+
"runtime_state": goal.runtime_state,
|
|
2220
|
+
"objective": goal.objective,
|
|
2221
|
+
"token_budget": goal.token_budget,
|
|
2222
|
+
"tokens_used": goal.tokens_used,
|
|
2223
|
+
"notice": goal.notice,
|
|
2224
|
+
"notice_is_error": goal.notice_is_error,
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
|
|
2228
|
+
def _encode_reply_activity_module(
|
|
2229
|
+
activity: ReplyCardActivityModule | None,
|
|
2230
|
+
) -> dict[str, Any] | None:
|
|
2231
|
+
if activity is None:
|
|
2232
|
+
return None
|
|
2233
|
+
# Encode the sanitized frozen view even if a caller bypassed a wrapper.
|
|
2234
|
+
progress = _sanitize_turn_progress_manifest(activity.progress)
|
|
2235
|
+
return {
|
|
2236
|
+
"progress": _encode_turn_progress_manifest(progress),
|
|
2237
|
+
"terminal_status": activity.terminal_status,
|
|
2238
|
+
"collapsed": activity.collapsed,
|
|
2239
|
+
"hidden_steps": activity.hidden_steps,
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
|
|
2243
|
+
def _turn_file_label(value: str) -> str:
|
|
2244
|
+
visible = "".join(
|
|
2245
|
+
character if character.isprintable() else "�" for character in value
|
|
2246
|
+
)
|
|
2247
|
+
return html.escape(visible, quote=False).replace("`", "ˋ")
|