feat(session): maintain canonical task state
This commit is contained in:
1 parent
6d4ab9ddac
commit
f7ab0160e8
8 files changed
+338
-15
No files matched your search
@@ -1,8 +1,31 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$Goal,[string[]]$ChangedFiles=@(),[string[]]$Verified=@(),[string[]]$Pending=@(),[string[]]$ImportantFindings=@(),[switch]$Pointer)
|
||||
param(
|
||||
[string]$Workspace = (Get-Location).Path,
|
||||
[AllowEmptyString()][string]$Goal,
|
||||
[AllowEmptyCollection()][string[]]$Decisions,
|
||||
[AllowEmptyCollection()][string[]]$ChangedFiles,
|
||||
[AllowEmptyCollection()][string[]]$Verified,
|
||||
[AllowEmptyCollection()][string[]]$Pending,
|
||||
[AllowEmptyCollection()][string[]]$Risks,
|
||||
[AllowEmptyCollection()][string[]]$ImportantFindings,
|
||||
[switch]$Pointer,
|
||||
[switch]$Reset
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path $PSScriptRoot -Parent; $dir = Join-Path $root '.ai-session'; $path = Join-Path $dir 'state.json'
|
||||
if ($Pointer) { if (Test-Path $path) { Write-Output 'Task state is available in .ai-session/state.json; read it only if needed.' }; exit 0 }
|
||||
function Limit([string[]]$items) { @($items | Where-Object { $_ } | Select-Object -First 12 | ForEach-Object { if ($_.Length -gt 180) { $_.Substring(0,180) } else { $_ } }) }
|
||||
New-Item $dir -ItemType Directory -Force | Out-Null
|
||||
[ordered]@{ goal=if($Goal){$Goal.Substring(0,[Math]::Min(240,$Goal.Length))}else{''}; changedFiles=Limit $ChangedFiles; verified=Limit $Verified; pending=Limit $Pending; importantFindings=Limit $ImportantFindings; updatedAt=(Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Depth 4 | Set-Content $path -Encoding UTF8
|
||||
$helper = Join-Path (Split-Path $PSScriptRoot -Parent) 'shared/hooks/scripts/session-state.py'
|
||||
$values = @{ workspace = $Workspace }
|
||||
$fields = @{ Goal = 'goal'; Decisions = 'decisions'; ChangedFiles = 'changed'; Verified = 'verified'; Pending = 'open'; Risks = 'risks' }
|
||||
foreach ($parameter in $fields.Keys) {
|
||||
if ($PSBoundParameters.ContainsKey($parameter)) { $values[$fields[$parameter]] = $PSBoundParameters[$parameter] }
|
||||
}
|
||||
if ($PSBoundParameters.ContainsKey('ImportantFindings') -and -not $PSBoundParameters.ContainsKey('Decisions')) {
|
||||
$values.decisions = $ImportantFindings
|
||||
}
|
||||
$OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||
$arguments = @($helper, '--update-json')
|
||||
if ($Pointer) { $arguments += '--pointer' }
|
||||
if ($Reset) { $arguments += '--reset' }
|
||||
$payload = $values | ConvertTo-Json -Depth 4 -Compress
|
||||
$payload = [regex]::Replace($payload, '[^\x00-\x7F]', { param($match) '\u{0:x4}' -f [int][char]$match.Value })
|
||||
$payload | & python @arguments
|
||||
exit $LASTEXITCODE
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd); dir="$root/.ai-session"; path="$dir/state.json"
|
||||
if [ "${1:-}" = --pointer ]; then [ -f "$path" ] && printf 'Task state is available in .ai-session/state.json; read it only if needed.\n'; exit 0; fi
|
||||
goal=${AI_TASK_GOAL:-}; goal=${goal:0:240}; goal_json=$(printf '%s' "$goal" | sed 's/[\\]/\\\\/g; s/"/\\"/g'); mkdir -p "$dir"
|
||||
printf '{"goal":"%s","changedFiles":[],"verified":[],"pending":[],"importantFindings":[],"updatedAt":"%s"}\n' "$goal_json" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$path"
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
arguments=()
|
||||
if [ "${AI_TASK_GOAL+x}" = x ]; then arguments+=(--goal "$AI_TASK_GOAL"); fi
|
||||
python3 "$root/shared/hooks/scripts/session-state.py" "${arguments[@]}" "$@"
|
||||
@@ -0,0 +1,170 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SHELLS = [
|
||||
('powershell', shutil.which('powershell') or shutil.which('pwsh')),
|
||||
('bash', str(Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe')
|
||||
if os.name == 'nt' else shutil.which('bash')),
|
||||
]
|
||||
|
||||
|
||||
class SessionStateHookTests(unittest.TestCase):
|
||||
def test_writer_migrates_legacy_state_and_requires_reset_for_new_goal(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
workspace = Path(directory)
|
||||
state_file = workspace / '.ai-session/state.json'
|
||||
state_file.parent.mkdir()
|
||||
state_file.write_text(json.dumps({'goal': 'Original', 'changedFiles': ['a'],
|
||||
'pending': ['b'], 'importantFindings': ['c']}))
|
||||
command = [sys.executable, str(ROOT / 'shared/hooks/scripts/session-state.py'),
|
||||
'--workspace', str(workspace)]
|
||||
result = subprocess.run(command + ['--verified', 'passed'], capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
state = json.loads(state_file.read_text())
|
||||
self.assertEqual(state['changed'], ['a'])
|
||||
self.assertEqual(state['open'], ['b'])
|
||||
self.assertEqual(state['decisions'], ['c'])
|
||||
self.assertFalse({'changedFiles', 'pending', 'importantFindings'} & state.keys())
|
||||
before = state_file.read_bytes()
|
||||
result = subprocess.run(command + ['--goal', 'New'], capture_output=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(state_file.read_bytes(), before)
|
||||
result = subprocess.run(command + ['--reset', '--goal', 'New'], capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
state = json.loads(state_file.read_text())
|
||||
self.assertEqual(state['goal'], 'New')
|
||||
self.assertEqual(state['verified'], [])
|
||||
self.assertEqual(state['changed'], [])
|
||||
state_file.write_text('{"goal":"New","verified":"invalid"}')
|
||||
before = state_file.read_bytes()
|
||||
result = subprocess.run(command + ['--open', 'next'], capture_output=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(state_file.read_bytes(), before)
|
||||
|
||||
def test_writers_preserve_partial_updates_and_escape_values(self):
|
||||
for shell, executable in SHELLS:
|
||||
if not executable or not Path(executable).exists():
|
||||
continue
|
||||
with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
repository = base / 'repository'
|
||||
shutil.copytree(ROOT / 'shared/hooks/scripts', repository / 'shared/hooks/scripts')
|
||||
(repository / 'scripts').mkdir()
|
||||
for filename in ('session-state.ps1', 'session-state.sh'):
|
||||
shutil.copy(ROOT / 'scripts' / filename, repository / 'scripts' / filename)
|
||||
workspace = base / 'workspace ü'
|
||||
workspace.mkdir()
|
||||
environment = dict(os.environ, TASK_WORKSPACE=str(workspace),
|
||||
TASK_GOAL='Repair "quotes"\n' + 'x' * 300)
|
||||
if shell == 'powershell':
|
||||
prefix = [executable, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command']
|
||||
commands = [
|
||||
'& ./scripts/session-state.ps1 -Workspace $env:TASK_WORKSPACE -Goal $env:TASK_GOAL -Decisions @("keep", "line two") -Pending @("check")',
|
||||
'& ./scripts/session-state.ps1 -Workspace $env:TASK_WORKSPACE -Verified @("passed")',
|
||||
'& ./scripts/session-state.ps1 -Workspace $env:TASK_WORKSPACE -Pointer',
|
||||
]
|
||||
else:
|
||||
prefix = [executable, '-c']
|
||||
commands = [
|
||||
'bash ./scripts/session-state.sh --workspace "$TASK_WORKSPACE" --goal "$TASK_GOAL" --decisions keep "line two" --open check',
|
||||
'bash ./scripts/session-state.sh --workspace "$TASK_WORKSPACE" --verified passed',
|
||||
'bash ./scripts/session-state.sh --workspace "$TASK_WORKSPACE" --pointer',
|
||||
]
|
||||
for command in commands:
|
||||
result = subprocess.run(prefix + [command], cwd=repository, capture_output=True,
|
||||
encoding='utf-8', timeout=20, env=environment)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
state_file = workspace / '.ai-session/state.json'
|
||||
self.assertIn(str(state_file), result.stdout)
|
||||
state = json.loads(state_file.read_text(encoding='utf-8-sig'))
|
||||
self.assertEqual(state['goal'], environment['TASK_GOAL'])
|
||||
self.assertEqual(state['decisions'], ['keep', 'line two'])
|
||||
self.assertEqual(state['open'], ['check'])
|
||||
self.assertEqual(state['verified'], ['passed'])
|
||||
self.assertEqual(state['workspace'], str(workspace))
|
||||
self.assertFalse((repository / '.ai-session').exists())
|
||||
self.assertEqual([p.name for p in state_file.parent.iterdir()], ['state.json'])
|
||||
|
||||
def run_hook(self, shell, executable, hook, config, workspace, payload):
|
||||
names = {'pointer': ('Show-SessionStatePointer.ps1', 'show-session-state-pointer.sh'),
|
||||
'compact': ('Record-Compact.ps1', 'record-compact.sh')}
|
||||
script = config / 'hooks/scripts' / names[hook][shell == 'bash']
|
||||
command = ([executable, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(script)]
|
||||
if shell == 'powershell' else [executable, str(script)])
|
||||
result = subprocess.run(command, cwd=workspace, input=payload, capture_output=True,
|
||||
encoding='utf-8', timeout=20)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stderr, '')
|
||||
return result.stdout
|
||||
|
||||
def test_workspace_selection_and_read_only_state(self):
|
||||
for shell, executable in SHELLS:
|
||||
if not executable or not Path(executable).exists():
|
||||
continue
|
||||
with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
config = base / 'installed config'
|
||||
shutil.copytree(ROOT / 'shared/hooks/scripts', config / 'hooks/scripts')
|
||||
workspace = base / 'working project'
|
||||
selected = base / 'selected project ü'
|
||||
empty = base / 'empty project'
|
||||
for folder in (config, workspace, selected, empty):
|
||||
folder.mkdir(exist_ok=True)
|
||||
for folder in (config, workspace, selected):
|
||||
(folder / '.ai-session').mkdir()
|
||||
(folder / '.ai-session/state.json').write_text('private state content')
|
||||
for hook in ('pointer', 'compact'):
|
||||
for payload, expected in (
|
||||
('', workspace),
|
||||
('{}', workspace),
|
||||
(json.dumps({'cwd': str(selected)}, ensure_ascii=False), selected),
|
||||
(json.dumps({'cwd': str(empty)}), None),
|
||||
('{"cwd":"relative/path"}', None),
|
||||
('{"cwd":null}', None),
|
||||
('{"cwd":12}', None),
|
||||
('{"cwd":""}', None),
|
||||
('{bad json', None),
|
||||
('[]', None),
|
||||
):
|
||||
with self.subTest(hook=hook, payload=payload):
|
||||
output = self.run_hook(shell, executable, hook, config, workspace, payload)
|
||||
if expected is None:
|
||||
self.assertEqual(output, '')
|
||||
else:
|
||||
self.assertIn(str(expected / '.ai-session/state.json'), output)
|
||||
self.assertNotIn(str(config), output)
|
||||
self.assertNotIn('private state content', output)
|
||||
for folder in (config, workspace, selected):
|
||||
self.assertEqual((folder / '.ai-session/state.json').read_text(), 'private state content')
|
||||
self.assertFalse((empty / '.ai-session').exists())
|
||||
events = (config / '.ai-session/telemetry.jsonl').read_text(encoding='utf-8-sig').splitlines()
|
||||
self.assertEqual(len(events), 10)
|
||||
self.assertTrue(all(json.loads(line)['event'] == 'compact' for line in events))
|
||||
|
||||
def test_missing_workspace_state_succeeds_without_config_fallback(self):
|
||||
for shell, executable in SHELLS:
|
||||
if not executable or not Path(executable).exists():
|
||||
continue
|
||||
with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory:
|
||||
base = Path(directory)
|
||||
config = base / 'config'
|
||||
shutil.copytree(ROOT / 'shared/hooks/scripts', config / 'hooks/scripts')
|
||||
(config / '.ai-session').mkdir()
|
||||
(config / '.ai-session/state.json').write_text('{}')
|
||||
workspace = base / 'workspace'
|
||||
workspace.mkdir()
|
||||
for hook in ('pointer', 'compact'):
|
||||
self.assertEqual(self.run_hook(shell, executable, hook, config, workspace, ''), '')
|
||||
self.assertEqual(list(workspace.iterdir()), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user